From 101a90b764ac4b40e0c91c1348a11835bb51259f Mon Sep 17 00:00:00 2001 From: accelerator-llc Date: Mon, 10 Aug 2026 23:09:49 +0800 Subject: [PATCH 01/12] =?UTF-8?q?=E8=B0=83=E6=95=B4libcu++=E5=A4=B4?= =?UTF-8?q?=E5=8C=85=E5=90=AB=E9=A1=BA=E5=BA=8F=EF=BC=8C=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?CUDA=2013=E4=B8=8B=E7=BC=96=E8=AF=91=E9=94=99=E8=AF=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- infini_train/src/kernels/cuda/cross_entropy.cu | 6 +++++- infini_train/src/kernels/cuda/reduction.cu | 6 +++++- infini_train/src/kernels/cuda/softmax.cu | 6 +++++- 3 files changed, 15 insertions(+), 3 deletions(-) diff --git a/infini_train/src/kernels/cuda/cross_entropy.cu b/infini_train/src/kernels/cuda/cross_entropy.cu index 7938329..16cb013 100644 --- a/infini_train/src/kernels/cuda/cross_entropy.cu +++ b/infini_train/src/kernels/cuda/cross_entropy.cu @@ -1,3 +1,8 @@ +// 注意:libcu++ 头(cuda/std/*)必须在任何标准库头之前包含(CUDA 13 自带 CUB 3.x 触发此分支)。 +#if defined(CUB_VERSION) && CUB_VERSION >= 200800 +#include +#endif + #include #include #include @@ -17,7 +22,6 @@ constexpr float kNegativeInfinity = -std::numeric_limits::infinity(); } #if defined(CUB_VERSION) && CUB_VERSION >= 200800 - #include using CubSumOp = ::cuda::std::plus<>; using CubMaxOp = ::cuda::maximum<>; using CubMinOp = ::cuda::minimum<>; diff --git a/infini_train/src/kernels/cuda/reduction.cu b/infini_train/src/kernels/cuda/reduction.cu index e85e113..047517e 100644 --- a/infini_train/src/kernels/cuda/reduction.cu +++ b/infini_train/src/kernels/cuda/reduction.cu @@ -1,3 +1,8 @@ +// 注意:libcu++ 头(cuda/std/*)必须在任何标准库头之前包含(CUDA 13 自带 CUB 3.x 触发此分支)。 +#if defined(CUB_VERSION) && CUB_VERSION >= 200800 +#include +#endif + #include #include @@ -14,7 +19,6 @@ constexpr float kInfinity = std::numeric_limits::infinity(); namespace { #if defined(CUB_VERSION) && CUB_VERSION >= 200800 - #include using CubSumOp = ::cuda::std::plus<>; using CubMaxOp = ::cuda::maximum<>; using CubMinOp = ::cuda::minimum<>; diff --git a/infini_train/src/kernels/cuda/softmax.cu b/infini_train/src/kernels/cuda/softmax.cu index 0f266d0..3121665 100644 --- a/infini_train/src/kernels/cuda/softmax.cu +++ b/infini_train/src/kernels/cuda/softmax.cu @@ -1,3 +1,8 @@ +// 注意:libcu++ 头(cuda/std/*)必须在任何标准库头之前包含(CUDA 13 自带 CUB 3.x 触发此分支)。 +#if defined(CUB_VERSION) && CUB_VERSION >= 200800 +#include +#endif + #include #include #include @@ -12,7 +17,6 @@ namespace infini_train::kernels::cuda { #if defined(CUB_VERSION) && CUB_VERSION >= 200800 - #include using CubSumOp = ::cuda::std::plus<>; using CubMaxOp = ::cuda::maximum<>; using CubMinOp = ::cuda::minimum<>; From ff182c1fdb36712cf09e6f261e9c5201f3e04d03 Mon Sep 17 00:00:00 2001 From: accelerator-llc Date: Tue, 11 Aug 2026 00:32:14 +0800 Subject: [PATCH 02/12] =?UTF-8?q?=E4=BD=9C=E4=B8=9A5+=E4=BD=9C=E4=B8=9A1-V?= =?UTF-8?q?1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 2 ++ infini_train/include/dispatcher.h | 18 ++++++++++++++---- infini_train/src/autograd/elementwise.cc | 12 ++++++++++-- 3 files changed, 26 insertions(+), 6 deletions(-) diff --git a/.gitignore b/.gitignore index 31d4260..af955d7 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,5 @@ build/ .cache/ .vscode/ Data/ +review +.claude diff --git a/infini_train/include/dispatcher.h b/infini_train/include/dispatcher.h index 5b91d85..a9de087 100644 --- a/infini_train/include/dispatcher.h +++ b/infini_train/include/dispatcher.h @@ -22,6 +22,7 @@ class KernelFunction { using FuncT = RetT (*)(ArgsT...); // TODO: 实现函数调用逻辑 + return reinterpret_cast(func_ptr_)(args...); } private: @@ -48,6 +49,9 @@ class Dispatcher { // TODO:实现kernel注册机制 // 功能描述:将kernel函数与设备类型、名称绑定 // =================================== 作业 =================================== + CHECK(!key_to_kernel_map_.contains(key)) + << "Kernel already registered: " << key.second << " on device: " << static_cast(key.first); // Fix CR#L52 + key_to_kernel_map_.emplace(key, KernelFunction(std::forward(kernel))); } private: @@ -55,8 +59,14 @@ class Dispatcher { }; } // namespace infini_train +#define INFINI_KERNEL_CONCAT_(a, b) a##b +#define INFINI_KERNEL_CONCAT(a, b) INFINI_KERNEL_CONCAT_(a, b) #define REGISTER_KERNEL(device, kernel_name, kernel_func) \ - // =================================== 作业 =================================== - // TODO:实现自动注册宏 - // 功能描述:在全局静态区注册kernel,避免显式初始化代码 - // =================================== 作业 =================================== + /* =================================== 作业 =================================== */ \ + /* TODO:实现自动注册宏 */ \ + /* 功能描述:在全局静态区注册kernel,避免显式初始化代码 */ \ + /* =================================== 作业 =================================== */ \ + static const bool INFINI_KERNEL_CONCAT(kernel_reg_, __COUNTER__) = \ + (infini_train::Dispatcher::Instance().Register( \ + std::make_pair(device, #kernel_name), kernel_func), \ + true); diff --git a/infini_train/src/autograd/elementwise.cc b/infini_train/src/autograd/elementwise.cc index 5a790a5..65d65fb 100644 --- a/infini_train/src/autograd/elementwise.cc +++ b/infini_train/src/autograd/elementwise.cc @@ -10,8 +10,12 @@ std::vector> Neg::Forward(const std::vector>(); + auto device = input->GetDevice().Type(); + auto kernel = Dispatcher::Instance().GetKernel({device, "NegForward"}); + return {kernel.Call>(input)}; } std::vector> Neg::Backward(const std::vector> &grad_outputs) { @@ -19,8 +23,12 @@ std::vector> Neg::Backward(const std::vector>(); + auto device = grad_output->GetDevice().Type(); + auto kernel = Dispatcher::Instance().GetKernel({device, "NegBackward"}); + return {kernel.Call>(grad_output)}; } std::vector> Reciprocal::Forward(const std::vector> &input_tensors) { From 555f21347e2ab5df21b5cfd8792a768ade1c3f22 Mon Sep 17 00:00:00 2001 From: accelerator-llc Date: Tue, 11 Aug 2026 01:18:26 +0800 Subject: [PATCH 03/12] =?UTF-8?q?=E4=BD=9C=E4=B8=9A4-V1=EF=BC=9A=E5=AE=9E?= =?UTF-8?q?=E7=8E=B0Tensor::Flatten=E4=B8=8EBackward?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- infini_train/src/tensor.cc | 30 +++++++++++++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/infini_train/src/tensor.cc b/infini_train/src/tensor.cc index 8f8c744..cd83e05 100644 --- a/infini_train/src/tensor.cc +++ b/infini_train/src/tensor.cc @@ -282,8 +282,28 @@ std::shared_ptr Tensor::Flatten(int64_t start, int64_t end) { // TODO:实现张量扁平化操作,将指定维度范围[start, end]内的所有维度合并为一个维度 // HINT: // =================================== 作业 =================================== + if (start < 0) { + start += dims_.size(); + } + if (end < 0) { + end += dims_.size(); + } + CHECK_GE(start, 0); + CHECK_LT(start, dims_.size()); + CHECK_GE(end, start); + CHECK_LT(end, dims_.size()); + + int64_t flattened_dim = 1; + for (int64_t dim = start; dim <= end; ++dim) { + flattened_dim *= dims_[dim]; + } - return std::make_shared(); + std::vector new_shape; + new_shape.insert(new_shape.end(), dims_.begin(), dims_.begin() + start); + new_shape.push_back(flattened_dim); + new_shape.insert(new_shape.end(), dims_.begin() + end + 1, dims_.end()); + + return Contiguous()->View(new_shape); } std::shared_ptr Tensor::Squeeze(int64_t dim) { @@ -358,6 +378,14 @@ void Tensor::Backward(std::shared_ptr gradient, bool retain_graph, bool // TODO:实现自动微分反向传播 // 功能描述:1. 计算当前张量对叶子节点的梯度 2. 支持多输出场景的梯度累加 // =================================== 作业 =================================== + if (!gradient) { + // 无参调用(对应 PyTorch 标量输出 tensor.backward() 语义):默认梯度为全 1 + gradient = std::make_shared(dims_, dtype_, GetDevice()); + gradient->Fill(1.0f); + } + if (grad_fn_) { + grad_fn_->BackwardPartial(gradient, output_idx_); + } } void Tensor::ZeroGrad() { From b16a43a2c240bd893fc18dbdcde91731fc8df590 Mon Sep 17 00:00:00 2001 From: accelerator-llc Date: Tue, 11 Aug 2026 01:41:36 +0800 Subject: [PATCH 04/12] =?UTF-8?q?=E4=BD=9C=E4=B8=9A4-V2=EF=BC=9A=E4=BF=AE?= =?UTF-8?q?=E5=A4=8DBackward=E6=A2=AF=E5=BA=A6=E6=A0=A1=E9=AA=8C=E4=B8=8EF?= =?UTF-8?q?latten=E9=9B=B6=E7=BB=B4=E7=9F=AD=E8=B7=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- infini_train/src/tensor.cc | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/infini_train/src/tensor.cc b/infini_train/src/tensor.cc index cd83e05..b07a188 100644 --- a/infini_train/src/tensor.cc +++ b/infini_train/src/tensor.cc @@ -282,6 +282,10 @@ std::shared_ptr Tensor::Flatten(int64_t start, int64_t end) { // TODO:实现张量扁平化操作,将指定维度范围[start, end]内的所有维度合并为一个维度 // HINT: // =================================== 作业 =================================== + if (dims_.empty()) { + // Fix CR#L285-L294:零维张量(标量)无维度可合并,返回原张量,对齐 PyTorch 0 维 flatten 语义 + return shared_from_this(); + } if (start < 0) { start += dims_.size(); } @@ -379,13 +383,22 @@ void Tensor::Backward(std::shared_ptr gradient, bool retain_graph, bool // 功能描述:1. 计算当前张量对叶子节点的梯度 2. 支持多输出场景的梯度累加 // =================================== 作业 =================================== if (!gradient) { - // 无参调用(对应 PyTorch 标量输出 tensor.backward() 语义):默认梯度为全 1 + // 无参调用(对应 PyTorch 标量输出 tensor.backward() 语义):默认梯度为全 1 // Fix CR#L381-L385 + // 注:非标量输出同样构造全 1 梯度(数学上等价于显式 ones 梯度),与 PyTorch 的标量检查不同, + // 属教学框架的宽容语义;默认梯度经 Fill kernel 按 float 填充,当前仅支持框架使用的 float32 路径。 gradient = std::make_shared(dims_, dtype_, GetDevice()); gradient->Fill(1.0f); + } else { + // Fix CR#L386-L388:透传前校验梯度形状与设备一致,防止元素错位静默累加与越界写 + CHECK(gradient->Dims() == dims_) << "Backward gradient shape mismatch"; + CHECK_EQ(static_cast(gradient->GetDevice().Type()), static_cast(GetDevice().Type())) + << "Backward gradient device mismatch"; } if (grad_fn_) { grad_fn_->BackwardPartial(gradient, output_idx_); } + // 契约(与 PyTorch 语义差异说明):同一图仅可传播一次,重复传播为未定义行为(根因在官方引擎传播后 + // 不重置依赖计数,修改受限);retain_graph/create_graph 参数当前无效果;无梯度函数(叶子)调用为空操作。 } void Tensor::ZeroGrad() { From fcfc295f6988475a23a3acab8fea529c00561ffb Mon Sep 17 00:00:00 2001 From: accelerator-llc Date: Tue, 11 Aug 2026 02:00:21 +0800 Subject: [PATCH 05/12] =?UTF-8?q?=E4=BD=9C=E4=B8=9A2+=E4=BD=9C=E4=B8=9A3-V?= =?UTF-8?q?1=EF=BC=9A=E5=AE=9E=E7=8E=B0CPU=20Matmul=E4=B8=8EAdam?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/kernels/cpu/accumulate_grad.cc | 14 +++ infini_train/src/kernels/cpu/linear.cc | 91 ++++++++++++++++++- 2 files changed, 101 insertions(+), 4 deletions(-) diff --git a/infini_train/src/kernels/cpu/accumulate_grad.cc b/infini_train/src/kernels/cpu/accumulate_grad.cc index 55637cd..6c650c7 100644 --- a/infini_train/src/kernels/cpu/accumulate_grad.cc +++ b/infini_train/src/kernels/cpu/accumulate_grad.cc @@ -18,6 +18,20 @@ void AdamAccumulateGrad(const std::shared_ptr &grad, const std::shared_p // TODO:实现Adam优化器的梯度累积和参数更新 // REF: // =================================== 作业 =================================== + // 标准 Adam(含偏差校正):m = β1*m + (1-β1)*g,v = β2*v + (1-β2)*g², + // param -= lr * m̂ / (√v̂ + eps),其中 m̂ = m/(1-β1^t),v̂ = v/(1-β2^t),t 从 1 开始 + for (int64_t idx = 0; idx < param->NumElements(); ++idx) { + const float g = static_cast(grad->DataPtr())[idx]; + float ¶m_elem = static_cast(param->DataPtr())[idx]; + float &m_elem = static_cast(m->DataPtr())[idx]; + float &v_elem = static_cast(v->DataPtr())[idx]; + + m_elem = beta1 * m_elem + (1.0f - beta1) * g; + v_elem = beta2 * v_elem + (1.0f - beta2) * g * g; + const float m_hat = m_elem / (1.0f - std::pow(beta1, t)); + const float v_hat = v_elem / (1.0f - std::pow(beta2, t)); + param_elem -= learning_rate * m_hat / (std::sqrt(v_hat) + eps); + } } } // namespace infini_train::kernels::cpu diff --git a/infini_train/src/kernels/cpu/linear.cc b/infini_train/src/kernels/cpu/linear.cc index 140e756..70b2cfa 100644 --- a/infini_train/src/kernels/cpu/linear.cc +++ b/infini_train/src/kernels/cpu/linear.cc @@ -15,9 +15,43 @@ std::shared_ptr MatmulForward(const std::shared_ptr &input, cons // TODO:实现CPU上的矩阵乘法前向计算 // REF: // =================================== 作业 =================================== + const auto &input_dims = input->Dims(); + const auto &other_dims = other->Dims(); + CHECK_GE(input_dims.size(), 2); + CHECK_EQ(input_dims.size(), other_dims.size()); + + // input[batch..., rows, in_features] × other[batch..., in_features, out_features] -> output[batch..., rows, out_features] + const int64_t batch_ndim = input_dims.size() - 2; + const int64_t batch = + std::accumulate(input_dims.begin(), input_dims.begin() + batch_ndim, 1, std::multiplies{}); + const int64_t rows = input_dims[batch_ndim]; + const int64_t in_features = *input_dims.rbegin(); + const int64_t out_features = *other_dims.rbegin(); + for (int64_t dim = 0; dim < batch_ndim; ++dim) { + CHECK_EQ(input_dims[dim], other_dims[dim]); + } + CHECK_EQ(in_features, *(other_dims.rbegin() + 1)); - auto output = std::make_shared(); - return {output}; + auto output_dims = input_dims; + *output_dims.rbegin() = out_features; + auto output = std::make_shared(output_dims, DataType::kFLOAT32); + + const float *input_data = static_cast(input->DataPtr()); + const float *other_data = static_cast(other->DataPtr()); + float *output_data = static_cast(output->DataPtr()); + for (int64_t b = 0; b < batch; ++b) { + for (int64_t r = 0; r < rows; ++r) { + for (int64_t c = 0; c < out_features; ++c) { + float sum = 0.0f; + for (int64_t k = 0; k < in_features; ++k) { + sum += input_data[(b * rows + r) * in_features + k] + * other_data[(b * in_features + k) * out_features + c]; + } + output_data[(b * rows + r) * out_features + c] = sum; + } + } + } + return output; } std::tuple, std::shared_ptr> @@ -27,9 +61,58 @@ MatmulBackward(const std::shared_ptr &input, const std::shared_ptrDims(); + const auto &other_dims = other->Dims(); + const auto &grad_output_dims = grad_output->Dims(); + CHECK_GE(input_dims.size(), 2); + CHECK_EQ(input_dims.size(), other_dims.size()); + + // grad_input = grad_output × other^T,grad_other = input^T × grad_output + const int64_t batch_ndim = input_dims.size() - 2; + const int64_t batch = + std::accumulate(input_dims.begin(), input_dims.begin() + batch_ndim, 1, std::multiplies{}); + const int64_t rows = input_dims[batch_ndim]; + const int64_t in_features = *input_dims.rbegin(); + const int64_t out_features = *other_dims.rbegin(); + for (int64_t dim = 0; dim < batch_ndim; ++dim) { + CHECK_EQ(input_dims[dim], other_dims[dim]); + } + CHECK_EQ(in_features, *(other_dims.rbegin() + 1)); - auto grad_input = std::make_shared(); - auto grad_other = std::make_shared(); + auto output_dims = input_dims; + *output_dims.rbegin() = out_features; + CHECK(grad_output_dims == output_dims); + + auto grad_input = std::make_shared(input_dims, DataType::kFLOAT32); + auto grad_other = std::make_shared(other_dims, DataType::kFLOAT32); + + const float *input_data = static_cast(input->DataPtr()); + const float *other_data = static_cast(other->DataPtr()); + const float *grad_output_data = static_cast(grad_output->DataPtr()); + float *grad_input_data = static_cast(grad_input->DataPtr()); + float *grad_other_data = static_cast(grad_other->DataPtr()); + for (int64_t b = 0; b < batch; ++b) { + for (int64_t r = 0; r < rows; ++r) { + for (int64_t k = 0; k < in_features; ++k) { + float sum = 0.0f; + for (int64_t c = 0; c < out_features; ++c) { + sum += grad_output_data[(b * rows + r) * out_features + c] + * other_data[(b * in_features + k) * out_features + c]; + } + grad_input_data[(b * rows + r) * in_features + k] = sum; + } + } + for (int64_t k = 0; k < in_features; ++k) { + for (int64_t c = 0; c < out_features; ++c) { + float sum = 0.0f; + for (int64_t r = 0; r < rows; ++r) { + sum += input_data[(b * rows + r) * in_features + k] + * grad_output_data[(b * rows + r) * out_features + c]; + } + grad_other_data[(b * in_features + k) * out_features + c] = sum; + } + } + } return {grad_input, grad_other}; } From 9e6487fafbec84792ffd6e1cf9f368c828364a4a Mon Sep 17 00:00:00 2001 From: accelerator-llc Date: Tue, 11 Aug 2026 02:19:58 +0800 Subject: [PATCH 06/12] =?UTF-8?q?=E4=BD=9C=E4=B8=9A2+=E4=BD=9C=E4=B8=9A3-V?= =?UTF-8?q?2=EF=BC=9A=E5=BE=AA=E7=8E=AF=E7=BC=93=E5=AD=98=E4=BC=98?= =?UTF-8?q?=E5=8C=96=E4=B8=8EAdam=E5=81=8F=E5=B7=AE=E5=9B=A0=E5=AD=90?= =?UTF-8?q?=E4=B8=8A=E6=8F=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/kernels/cpu/accumulate_grad.cc | 11 ++++-- infini_train/src/kernels/cpu/linear.cc | 36 ++++++++++++------- 2 files changed, 33 insertions(+), 14 deletions(-) diff --git a/infini_train/src/kernels/cpu/accumulate_grad.cc b/infini_train/src/kernels/cpu/accumulate_grad.cc index 6c650c7..7263f87 100644 --- a/infini_train/src/kernels/cpu/accumulate_grad.cc +++ b/infini_train/src/kernels/cpu/accumulate_grad.cc @@ -1,3 +1,4 @@ +#include // Fix CR#L1-L5 #include #include @@ -20,6 +21,12 @@ void AdamAccumulateGrad(const std::shared_ptr &grad, const std::shared_p // =================================== 作业 =================================== // 标准 Adam(含偏差校正):m = β1*m + (1-β1)*g,v = β2*v + (1-β2)*g², // param -= lr * m̂ / (√v̂ + eps),其中 m̂ = m/(1-β1^t),v̂ = v/(1-β2^t),t 从 1 开始 + CHECK_EQ(grad->NumElements(), param->NumElements()); // Fix CR#L23-L27:防直接调用 kernel 时越界读写 + CHECK_EQ(m->NumElements(), param->NumElements()); + CHECK_EQ(v->NumElements(), param->NumElements()); + // 偏差校正因子仅依赖步数,与元素无关,提升到循环外计算一次(double 对齐 PyTorch 标量路径的双精度惯例) // Fix CR#L31-L32 + const double beta1_t = std::pow(static_cast(beta1), static_cast(t)); + const double beta2_t = std::pow(static_cast(beta2), static_cast(t)); for (int64_t idx = 0; idx < param->NumElements(); ++idx) { const float g = static_cast(grad->DataPtr())[idx]; float ¶m_elem = static_cast(param->DataPtr())[idx]; @@ -28,8 +35,8 @@ void AdamAccumulateGrad(const std::shared_ptr &grad, const std::shared_p m_elem = beta1 * m_elem + (1.0f - beta1) * g; v_elem = beta2 * v_elem + (1.0f - beta2) * g * g; - const float m_hat = m_elem / (1.0f - std::pow(beta1, t)); - const float v_hat = v_elem / (1.0f - std::pow(beta2, t)); + const float m_hat = static_cast(m_elem / (1.0 - beta1_t)); + const float v_hat = static_cast(v_elem / (1.0 - beta2_t)); param_elem -= learning_rate * m_hat / (std::sqrt(v_hat) + eps); } } diff --git a/infini_train/src/kernels/cpu/linear.cc b/infini_train/src/kernels/cpu/linear.cc index 70b2cfa..deb6abe 100644 --- a/infini_train/src/kernels/cpu/linear.cc +++ b/infini_train/src/kernels/cpu/linear.cc @@ -21,6 +21,7 @@ std::shared_ptr MatmulForward(const std::shared_ptr &input, cons CHECK_EQ(input_dims.size(), other_dims.size()); // input[batch..., rows, in_features] × other[batch..., in_features, out_features] -> output[batch..., rows, out_features] + // 逐 batch 严格相等(batch 维不支持 torch.matmul 的广播语义) // Fix CR#L30-L33 const int64_t batch_ndim = input_dims.size() - 2; const int64_t batch = std::accumulate(input_dims.begin(), input_dims.begin() + batch_ndim, 1, std::multiplies{}); @@ -39,15 +40,19 @@ std::shared_ptr MatmulForward(const std::shared_ptr &input, cons const float *input_data = static_cast(input->DataPtr()); const float *other_data = static_cast(other->DataPtr()); float *output_data = static_cast(output->DataPtr()); + // 循环次序 r->k->c(内层 c):other 与 output 沿连续方向访问(缓存友好),output 先清零再累积 // Fix CR#L42-L53 + const int64_t output_size = batch * rows * out_features; + for (int64_t idx = 0; idx < output_size; ++idx) { + output_data[idx] = 0.0f; + } for (int64_t b = 0; b < batch; ++b) { for (int64_t r = 0; r < rows; ++r) { - for (int64_t c = 0; c < out_features; ++c) { - float sum = 0.0f; - for (int64_t k = 0; k < in_features; ++k) { - sum += input_data[(b * rows + r) * in_features + k] - * other_data[(b * in_features + k) * out_features + c]; + for (int64_t k = 0; k < in_features; ++k) { + const float a = input_data[(b * rows + r) * in_features + k]; + for (int64_t c = 0; c < out_features; ++c) { + output_data[(b * rows + r) * out_features + c] + += a * other_data[(b * in_features + k) * out_features + c]; } - output_data[(b * rows + r) * out_features + c] = sum; } } } @@ -68,6 +73,7 @@ MatmulBackward(const std::shared_ptr &input, const std::shared_ptr{}); @@ -91,7 +97,14 @@ MatmulBackward(const std::shared_ptr &input, const std::shared_ptr(grad_output->DataPtr()); float *grad_input_data = static_cast(grad_input->DataPtr()); float *grad_other_data = static_cast(grad_other->DataPtr()); + // grad_other[b][k][c] = Σ_r input[b][r][k] * grad_output[b][r][c] + // 循环次序 k->r->c(内层 c):grad_output 与 grad_other 沿连续方向访问(缓存友好),grad_other 先清零 // Fix CR#L94-L115 + const int64_t grad_other_size = batch * in_features * out_features; + for (int64_t idx = 0; idx < grad_other_size; ++idx) { + grad_other_data[idx] = 0.0f; + } for (int64_t b = 0; b < batch; ++b) { + // grad_input[b][r][k] = Σ_c grad_output[b][r][c] * other[b][k][c](c 最内层,两操作数连续访问) for (int64_t r = 0; r < rows; ++r) { for (int64_t k = 0; k < in_features; ++k) { float sum = 0.0f; @@ -103,13 +116,12 @@ MatmulBackward(const std::shared_ptr &input, const std::shared_ptr Date: Tue, 11 Aug 2026 02:45:26 +0800 Subject: [PATCH 07/12] =?UTF-8?q?=E4=BD=9C=E4=B8=9A2+=E4=BD=9C=E4=B8=9A3-C?= =?UTF-8?q?UDA-V1=EF=BC=9A=E5=AE=9E=E7=8E=B0CUDA=20Matmul=E4=B8=8EAdam?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/kernels/cuda/accumulate_grad.cu | 38 +++++ infini_train/src/kernels/cuda/linear.cu | 138 +++++++++++++++++- 2 files changed, 173 insertions(+), 3 deletions(-) diff --git a/infini_train/src/kernels/cuda/accumulate_grad.cu b/infini_train/src/kernels/cuda/accumulate_grad.cu index 5f977c3..74518d5 100644 --- a/infini_train/src/kernels/cuda/accumulate_grad.cu +++ b/infini_train/src/kernels/cuda/accumulate_grad.cu @@ -22,6 +22,27 @@ void AccumulateGrad(const std::shared_ptr &gradient, float rate, const s AccumulateGradKernel<<>>(grad_ptr, rate, tensor_ptr, num_elements); } +__global__ void AdamAccumulateGradKernel(const float *__restrict__ grad_ptr, float *__restrict__ param_ptr, + float *__restrict__ m_ptr, float *__restrict__ v_ptr, float learning_rate, + float beta1, float beta2, float eps, double beta1_t, double beta2_t, + size_t num_elements) { + int idx = blockIdx.x * blockDim.x + threadIdx.x; + if (idx < num_elements) { + const float g = grad_ptr[idx]; + float &p = param_ptr[idx]; + float &m = m_ptr[idx]; + float &v = v_ptr[idx]; + + // 标准 Adam(含偏差校正),公式与 CPU 版一致: + // m = β1*m + (1-β1)*g,v = β2*v + (1-β2)*g²,p -= lr * m̂ / (√v̂ + eps) + m = beta1 * m + (1.0f - beta1) * g; + v = beta2 * v + (1.0f - beta2) * g * g; + const float m_hat = static_cast(m / (1.0 - beta1_t)); + const float v_hat = static_cast(v / (1.0 - beta2_t)); + p -= learning_rate * m_hat / (sqrtf(v_hat) + eps); + } +} + void AdamAccumulateGrad(const std::shared_ptr &grad, const std::shared_ptr ¶m, const std::shared_ptr &m, const std::shared_ptr &v, float learning_rate, float beta1, float beta2, float eps, int64_t t) { @@ -29,6 +50,23 @@ void AdamAccumulateGrad(const std::shared_ptr &grad, const std::shared_p // TODO:实现Adam优化器的梯度累积和参数更新 // REF: // =================================== 作业 =================================== + CHECK_EQ(grad->NumElements(), param->NumElements()); + CHECK_EQ(m->NumElements(), param->NumElements()); + CHECK_EQ(v->NumElements(), param->NumElements()); + // 偏差校正因子仅依赖步数,与元素无关,host 侧计算一次(double,与 CPU 版一致) + const double beta1_t = std::pow(static_cast(beta1), static_cast(t)); + const double beta2_t = std::pow(static_cast(beta2), static_cast(t)); + + size_t num_elements = param->NumElements(); + const float *grad_ptr = static_cast(grad->DataPtr()); + float *param_ptr = static_cast(param->DataPtr()); + float *m_ptr = static_cast(m->DataPtr()); + float *v_ptr = static_cast(v->DataPtr()); + + int threads_per_block = 256; + int num_blocks = (num_elements + threads_per_block - 1) / threads_per_block; + AdamAccumulateGradKernel<<>>(grad_ptr, param_ptr, m_ptr, v_ptr, learning_rate, + beta1, beta2, eps, beta1_t, beta2_t, num_elements); } } // namespace infini_train::kernels::cuda diff --git a/infini_train/src/kernels/cuda/linear.cu b/infini_train/src/kernels/cuda/linear.cu index efaaaa6..6075aec 100644 --- a/infini_train/src/kernels/cuda/linear.cu +++ b/infini_train/src/kernels/cuda/linear.cu @@ -23,16 +23,110 @@ namespace infini_train::kernels::cuda { } \ } while (0) +__global__ void MatmulForwardKernel(const float *__restrict__ input, const float *__restrict__ other, + float *__restrict__ output, int rows, int in_features, int out_features, + int total) { + int idx = blockIdx.x * blockDim.x + threadIdx.x; + if (idx >= total) { + return; + } + int col = idx % out_features; + int tmp = idx / out_features; + int row = tmp % rows; + int batch = tmp / rows; + + // 每个线程串行累加 k(k 升序,与 CPU 版累加顺序一致,保证精确比较的确定性) + float sum = 0.0f; + const float *input_row = input + (batch * rows + row) * in_features; + const float *other_base = other + (batch * in_features) * out_features + col; + for (int k = 0; k < in_features; ++k) { + sum += input_row[k] * other_base[k * out_features]; + } + output[idx] = sum; +} + std::shared_ptr MatmulForward(const std::shared_ptr &input, const std::shared_ptr &other) { // =================================== 作业 =================================== // TODO:实现CUDA上的矩阵乘法前向计算 // REF: // =================================== 作业 =================================== + const auto &input_dims = input->Dims(); + const auto &other_dims = other->Dims(); + CHECK_GE(input_dims.size(), 2); + CHECK_EQ(input_dims.size(), other_dims.size()); + + // input[batch..., rows, in_features] × other[batch..., in_features, out_features] -> output[batch..., rows, out_features] + // 逐 batch 严格相等(batch 维不支持 torch.matmul 的广播语义),语义与 CPU 版一致 + const int64_t batch_ndim = input_dims.size() - 2; + const int64_t batch = + std::accumulate(input_dims.begin(), input_dims.begin() + batch_ndim, 1, std::multiplies{}); + const int64_t rows = input_dims[batch_ndim]; + const int64_t in_features = *input_dims.rbegin(); + const int64_t out_features = *other_dims.rbegin(); + for (int64_t dim = 0; dim < batch_ndim; ++dim) { + CHECK_EQ(input_dims[dim], other_dims[dim]); + } + CHECK_EQ(in_features, *(other_dims.rbegin() + 1)); + + auto output_dims = input_dims; + *output_dims.rbegin() = out_features; + auto output = std::make_shared(output_dims, DataType::kFLOAT32, input->GetDevice()); - auto output = std::make_shared(); + // 一元素一线程 + 边界检查(网格按 CEIL_DIV 划分,与官方 kernel 风格一致) + const int64_t total = batch * rows * out_features; + int threads_per_block = 256; + int num_blocks = static_cast((total + threads_per_block - 1) / threads_per_block); + MatmulForwardKernel<<>>( + static_cast(input->DataPtr()), static_cast(other->DataPtr()), + static_cast(output->DataPtr()), static_cast(rows), static_cast(in_features), + static_cast(out_features), static_cast(total)); return output; } +__global__ void MatmulBackwardGradInputKernel(const float *__restrict__ grad_output, const float *__restrict__ other, + float *__restrict__ grad_input, int rows, int in_features, + int out_features, int total) { + int idx = blockIdx.x * blockDim.x + threadIdx.x; + if (idx >= total) { + return; + } + int k = idx % in_features; + int tmp = idx / in_features; + int row = tmp % rows; + int batch = tmp / rows; + + // grad_input[batch][row][k] = Σ_c grad_output[batch][row][c] * other[batch][k][c] + float sum = 0.0f; + const float *grad_output_row = grad_output + (batch * rows + row) * out_features; + const float *other_base = other + (batch * in_features + k) * out_features; + for (int c = 0; c < out_features; ++c) { + sum += grad_output_row[c] * other_base[c]; + } + grad_input[idx] = sum; +} + +__global__ void MatmulBackwardGradOtherKernel(const float *__restrict__ input, const float *__restrict__ grad_output, + float *__restrict__ grad_other, int rows, int in_features, + int out_features, int total) { + int idx = blockIdx.x * blockDim.x + threadIdx.x; + if (idx >= total) { + return; + } + int col = idx % out_features; + int tmp = idx / out_features; + int k = tmp % in_features; + int batch = tmp / in_features; + + // grad_other[batch][k][col] = Σ_r input[batch][row][k] * grad_output[batch][row][col] + float sum = 0.0f; + const float *input_base = input + (batch * rows) * in_features + k; + const float *grad_output_base = grad_output + (batch * rows) * out_features + col; + for (int r = 0; r < rows; ++r) { + sum += input_base[r * in_features] * grad_output_base[r * out_features]; + } + grad_other[idx] = sum; +} + std::tuple, std::shared_ptr> MatmulBackward(const std::shared_ptr &input, const std::shared_ptr &other, const std::shared_ptr &grad_output) { @@ -40,9 +134,47 @@ MatmulBackward(const std::shared_ptr &input, const std::shared_ptrDims(); + const auto &other_dims = other->Dims(); + const auto &grad_output_dims = grad_output->Dims(); + CHECK_GE(input_dims.size(), 2); + CHECK_EQ(input_dims.size(), other_dims.size()); + + // grad_input = grad_output × other^T,grad_other = input^T × grad_output + // 逐 batch 严格相等(batch 维不支持 torch.matmul 的广播语义),语义与 CPU 版一致 + const int64_t batch_ndim = input_dims.size() - 2; + const int64_t batch = + std::accumulate(input_dims.begin(), input_dims.begin() + batch_ndim, 1, std::multiplies{}); + const int64_t rows = input_dims[batch_ndim]; + const int64_t in_features = *input_dims.rbegin(); + const int64_t out_features = *other_dims.rbegin(); + for (int64_t dim = 0; dim < batch_ndim; ++dim) { + CHECK_EQ(input_dims[dim], other_dims[dim]); + } + CHECK_EQ(in_features, *(other_dims.rbegin() + 1)); - auto grad_input = std::make_shared(); - auto grad_other = std::make_shared(); + auto output_dims = input_dims; + *output_dims.rbegin() = out_features; + CHECK(grad_output_dims == output_dims); + + auto grad_input = std::make_shared(input_dims, DataType::kFLOAT32, grad_output->GetDevice()); + auto grad_other = std::make_shared(other_dims, DataType::kFLOAT32, grad_output->GetDevice()); + + // 两个 kernel 均为一元素一线程 + 边界检查,串行内层循环顺序与 CPU 版一致 + const int64_t grad_input_total = batch * rows * in_features; + int threads_per_block = 256; + int num_blocks = static_cast((grad_input_total + threads_per_block - 1) / threads_per_block); + MatmulBackwardGradInputKernel<<>>( + static_cast(grad_output->DataPtr()), static_cast(other->DataPtr()), + static_cast(grad_input->DataPtr()), static_cast(rows), static_cast(in_features), + static_cast(out_features), static_cast(grad_input_total)); + + const int64_t grad_other_total = batch * in_features * out_features; + num_blocks = static_cast((grad_other_total + threads_per_block - 1) / threads_per_block); + MatmulBackwardGradOtherKernel<<>>( + static_cast(input->DataPtr()), static_cast(grad_output->DataPtr()), + static_cast(grad_other->DataPtr()), static_cast(rows), static_cast(in_features), + static_cast(out_features), static_cast(grad_other_total)); return {grad_input, grad_other}; } From 6fe582e0ef64fdd687b0180e07954dbba510ea42 Mon Sep 17 00:00:00 2001 From: accelerator-llc Date: Tue, 11 Aug 2026 03:27:01 +0800 Subject: [PATCH 08/12] =?UTF-8?q?=E4=BD=9C=E4=B8=9A2+=E4=BD=9C=E4=B8=9A3-C?= =?UTF-8?q?UDA-V2=EF=BC=9A=E4=BF=AE=E5=A4=8D=E6=B3=A8=E9=87=8A=E5=A3=B0?= =?UTF-8?q?=E7=A7=B0=E4=B8=8E=E7=B4=A2=E5=BC=95=E8=8C=83=E5=9B=B4=E9=98=B2?= =?UTF-8?q?=E6=8A=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/kernels/cuda/accumulate_grad.cu | 3 +++ infini_train/src/kernels/cuda/linear.cu | 21 +++++++++++++++++-- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/infini_train/src/kernels/cuda/accumulate_grad.cu b/infini_train/src/kernels/cuda/accumulate_grad.cu index 74518d5..b53a06e 100644 --- a/infini_train/src/kernels/cuda/accumulate_grad.cu +++ b/infini_train/src/kernels/cuda/accumulate_grad.cu @@ -58,6 +58,9 @@ void AdamAccumulateGrad(const std::shared_ptr &grad, const std::shared_p const double beta2_t = std::pow(static_cast(beta2), static_cast(t)); size_t num_elements = param->NumElements(); + // Fix CR#L29-L30:32 位索引范围防护(超过 int 范围时快速失败,避免后半元素静默不处理) + CHECK_LE(num_elements, static_cast(std::numeric_limits::max())); + // Fix CR#L25-L28:kernel 的 restrict 承诺要求写目标(param/m/v)与读源(grad)互不重叠(原地调用属未定义行为) const float *grad_ptr = static_cast(grad->DataPtr()); float *param_ptr = static_cast(param->DataPtr()); float *m_ptr = static_cast(m->DataPtr()); diff --git a/infini_train/src/kernels/cuda/linear.cu b/infini_train/src/kernels/cuda/linear.cu index 6075aec..45634b6 100644 --- a/infini_train/src/kernels/cuda/linear.cu +++ b/infini_train/src/kernels/cuda/linear.cu @@ -23,6 +23,7 @@ namespace infini_train::kernels::cuda { } \ } while (0) +// restrict 承诺:写目标(output)必须与读源(input/other)不重叠,原地调用属未定义行为 // Fix CR#L26-L28 __global__ void MatmulForwardKernel(const float *__restrict__ input, const float *__restrict__ other, float *__restrict__ output, int rows, int in_features, int out_features, int total) { @@ -35,7 +36,8 @@ __global__ void MatmulForwardKernel(const float *__restrict__ input, const float int row = tmp % rows; int batch = tmp / rows; - // 每个线程串行累加 k(k 升序,与 CPU 版累加顺序一致,保证精确比较的确定性) + // 每个线程串行累加 k(k 升序,固定累加顺序保证 GPU 侧运行位级确定; + // 与 CPU 版在容差内一致——nvcc 默认 FMA 融合(--fmad=true),存在 1 ULP 级差异) // Fix CR#L38 float sum = 0.0f; const float *input_row = input + (batch * rows + row) * in_features; const float *other_base = other + (batch * in_features) * out_features + col; @@ -67,6 +69,7 @@ std::shared_ptr MatmulForward(const std::shared_ptr &input, cons CHECK_EQ(input_dims[dim], other_dims[dim]); } CHECK_EQ(in_features, *(other_dims.rbegin() + 1)); + CHECK_EQ(static_cast(input->GetDevice().Type()), static_cast(other->GetDevice().Type())); // Fix CR#L160-L161 auto output_dims = input_dims; *output_dims.rbegin() = out_features; @@ -74,6 +77,8 @@ std::shared_ptr MatmulForward(const std::shared_ptr &input, cons // 一元素一线程 + 边界检查(网格按 CEIL_DIV 划分,与官方 kernel 风格一致) const int64_t total = batch * rows * out_features; + // Fix CR#L76-L82:32 位索引范围防护(total 超出 int 范围时快速失败,避免溢出为负导致静默空输出) + CHECK_LE(total, std::numeric_limits::max()); int threads_per_block = 256; int num_blocks = static_cast((total + threads_per_block - 1) / threads_per_block); MatmulForwardKernel<<>>( @@ -83,6 +88,7 @@ std::shared_ptr MatmulForward(const std::shared_ptr &input, cons return output; } +// restrict 承诺:写目标(grad_input)必须与读源(grad_output/other)不重叠,原地调用属未定义行为 // Fix CR#L26-L28 __global__ void MatmulBackwardGradInputKernel(const float *__restrict__ grad_output, const float *__restrict__ other, float *__restrict__ grad_input, int rows, int in_features, int out_features, int total) { @@ -96,6 +102,9 @@ __global__ void MatmulBackwardGradInputKernel(const float *__restrict__ grad_out int batch = tmp / rows; // grad_input[batch][row][k] = Σ_c grad_output[batch][row][c] * other[batch][k][c] + // c 串行累加保证 GPU 侧运行位级确定;warp 内相邻线程(k 连续)读 other 为按列访问(非 coalesced)—— + // A×B^T 结构在无共享内存的 naive 形态下两操作数无法同时行主序访问,当前映射已保证输出写 coalesced + // 与 grad_output 广播读,教学定位保持现状 // Fix CR#L86-L106, L98 float sum = 0.0f; const float *grad_output_row = grad_output + (batch * rows + row) * out_features; const float *other_base = other + (batch * in_features + k) * out_features; @@ -105,6 +114,7 @@ __global__ void MatmulBackwardGradInputKernel(const float *__restrict__ grad_out grad_input[idx] = sum; } +// restrict 承诺:写目标(grad_other)必须与读源(input/grad_output)不重叠,原地调用属未定义行为 // Fix CR#L26-L28 __global__ void MatmulBackwardGradOtherKernel(const float *__restrict__ input, const float *__restrict__ grad_output, float *__restrict__ grad_other, int rows, int in_features, int out_features, int total) { @@ -118,6 +128,7 @@ __global__ void MatmulBackwardGradOtherKernel(const float *__restrict__ input, c int batch = tmp / in_features; // grad_other[batch][k][col] = Σ_r input[batch][row][k] * grad_output[batch][row][col] + // r 串行累加保证 GPU 侧运行位级确定;warp 内相邻线程(col 连续)grad_output 与写均 coalesced // Fix CR#L120 float sum = 0.0f; const float *input_base = input + (batch * rows) * in_features + k; const float *grad_output_base = grad_output + (batch * rows) * out_features + col; @@ -152,6 +163,9 @@ MatmulBackward(const std::shared_ptr &input, const std::shared_ptr(input->GetDevice().Type()), static_cast(other->GetDevice().Type())); + CHECK_EQ(static_cast(input->GetDevice().Type()), static_cast(grad_output->GetDevice().Type())); auto output_dims = input_dims; *output_dims.rbegin() = out_features; @@ -160,8 +174,10 @@ MatmulBackward(const std::shared_ptr &input, const std::shared_ptr(input_dims, DataType::kFLOAT32, grad_output->GetDevice()); auto grad_other = std::make_shared(other_dims, DataType::kFLOAT32, grad_output->GetDevice()); - // 两个 kernel 均为一元素一线程 + 边界检查,串行内层循环顺序与 CPU 版一致 + // 两个 kernel 均为一元素一线程 + 边界检查,串行内层循环保证 GPU 侧运行位级确定 const int64_t grad_input_total = batch * rows * in_features; + // Fix CR#L164-L177:32 位索引范围防护(total 超出 int 范围时快速失败,避免静默错误) + CHECK_LE(grad_input_total, std::numeric_limits::max()); int threads_per_block = 256; int num_blocks = static_cast((grad_input_total + threads_per_block - 1) / threads_per_block); MatmulBackwardGradInputKernel<<>>( @@ -170,6 +186,7 @@ MatmulBackward(const std::shared_ptr &input, const std::shared_ptr(out_features), static_cast(grad_input_total)); const int64_t grad_other_total = batch * in_features * out_features; + CHECK_LE(grad_other_total, std::numeric_limits::max()); num_blocks = static_cast((grad_other_total + threads_per_block - 1) / threads_per_block); MatmulBackwardGradOtherKernel<<>>( static_cast(input->DataPtr()), static_cast(grad_output->DataPtr()), From be106d54bd9f9e5130724245142656ffb4b716ed Mon Sep 17 00:00:00 2001 From: accelerator-llc Date: Tue, 11 Aug 2026 18:19:54 +0800 Subject: [PATCH 09/12] =?UTF-8?q?=E4=BD=9C=E4=B8=9A6-V1=EF=BC=9A=E5=AE=9E?= =?UTF-8?q?=E7=8E=B0GPT-2=E6=95=B0=E6=8D=AE=E8=A7=A3=E6=9E=90=E4=B8=8E?= =?UTF-8?q?=E6=96=87=E6=9C=AC=E7=94=9F=E6=88=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- example/common/tiny_shakespeare_dataset.cc | 30 ++++++++++- example/common/tokenizer.cc | 61 +++++++++++++++++++++- 2 files changed, 89 insertions(+), 2 deletions(-) diff --git a/example/common/tiny_shakespeare_dataset.cc b/example/common/tiny_shakespeare_dataset.cc index 3bc5f1b..e07dc6e 100644 --- a/example/common/tiny_shakespeare_dataset.cc +++ b/example/common/tiny_shakespeare_dataset.cc @@ -61,10 +61,38 @@ TinyShakespeareFile ReadTinyShakespeareFile(const std::string &path, size_t sequ | magic(4B) | version(4B) | num_toks(4B) | reserved(1012B) | token数据 | ---------------------------------------------------------------------------------- =================================== 作业 =================================== */ + std::ifstream ifs(path, std::ios::binary); + CHECK(ifs.is_open()) << "Failed to open file: " << path; + + auto header = ReadSeveralBytesFromIfstream(1024, &ifs); + const int magic = BytesToType(header, 0); + const int num_toks = BytesToType(header, 8); + CHECK(kTypeMap.contains(magic)) << "Unsupported magic number: " << magic; + + TinyShakespeareFile file; + file.type = kTypeMap.at(magic); + const size_t token_size = kTypeToSize.at(file.type); + + // 读 token 流并转为 int64 张量:CrossEntropy 的 target 要求 int64, + // 且 operator[] 中 y 的偏移 sizeof(int64_t) = 8 字节恰为 1 个 int64 token(右移 1 token 语义) + auto token_bytes = ReadSeveralBytesFromIfstream(static_cast(num_toks) * token_size, &ifs); + const size_t num_samples = static_cast(num_toks) / sequence_length; + file.dims = {static_cast(num_samples), static_cast(sequence_length)}; + file.tensor = infini_train::Tensor(file.dims, DataType::kINT64); + int64_t *tensor_data = static_cast(file.tensor.DataPtr()); + for (size_t i = 0; i < num_samples * sequence_length; ++i) { + tensor_data[i] = (token_size == 2) ? BytesToType(token_bytes, i * 2) + : BytesToType(token_bytes, i * 4); + } + return file; } } // namespace -TinyShakespeareDataset::TinyShakespeareDataset(const std::string &filepath, size_t sequence_length) { +TinyShakespeareDataset::TinyShakespeareDataset(const std::string &filepath, size_t sequence_length) + : text_file_(ReadTinyShakespeareFile(filepath, sequence_length)), + sequence_length_(sequence_length), + sequence_size_in_bytes_(sequence_length * sizeof(int64_t)), + num_samples_(text_file_.dims[0]) { // =================================== 作业 =================================== // TODO:初始化数据集实例 // HINT: 调用ReadTinyShakespeareFile加载数据文件 diff --git a/example/common/tokenizer.cc b/example/common/tokenizer.cc index 23b9537..c9ee6b9 100644 --- a/example/common/tokenizer.cc +++ b/example/common/tokenizer.cc @@ -78,6 +78,22 @@ Tokenizer::Tokenizer(const std::string &filepath) { | magic(4B) | version(4B) | vocab_size(4B) | reserved(1012B) | token词表数据 | ---------------------------------------------------------------------------------- ===================================== 作业 ===================================== */ + std::ifstream ifs(filepath, std::ios::binary); + CHECK(ifs.is_open()) << "Failed to open tokenizer file: " << filepath; + + auto header = ReadSeveralBytesFromIfstream(1024, &ifs); + magic_number_ = BytesToType(header, 0); + vocab_size_ = BytesToType(header, 8); + eot_token_ = BytesToType(header, 12); + CHECK(kEotMap.contains(magic_number_)) << "Unsupported tokenizer magic number: " << magic_number_; + + // 词表格式(与 llm.c gpt2_tokenizer.bin 一致):每个 token 为 1 字节长度前缀 + 原始字节 + token_table_.reserve(vocab_size_); + for (uint32_t i = 0; i < vocab_size_; ++i) { + const uint8_t len = BytesToType(ReadSeveralBytesFromIfstream(1, &ifs), 0); + auto bytes = ReadSeveralBytesFromIfstream(len, &ifs); + token_table_.emplace_back(reinterpret_cast(bytes.data()), len); + } } std::string Tokenizer::Decode(uint32_t token_id) const { @@ -85,7 +101,8 @@ std::string Tokenizer::Decode(uint32_t token_id) const { TODO:实现token_id到文本的转换 功能描述:根据token_id返回对应的文本片段 ===================================== 作业 ===================================== */ - return ""; + CHECK_LT(token_id, vocab_size_) << "token_id out of range: " << token_id; + return token_table_[token_id]; } void Tokenizer::GenerateText(infini_train::nn::Module &model, uint32_t batch_size, uint32_t sequence_length, @@ -111,6 +128,48 @@ void Tokenizer::GenerateText(infini_train::nn::Module &model, uint32_t batch_siz TODO:实现单步文本生成逻辑 HINT:调用model.Forward推理获取logits,根据推理结果进行随机采样,调用Decode获取文本结果 ===================================== 作业 ===================================== */ + // 生成场景无 Backward:临时禁用参数梯度使前向不建 autograd 图(Function 即时释放), + // 避免算子 saved_tensors_ 的循环引用在无 Backward 场景下导致显存逐步入泄漏 + if (t == prompt_len) { + for (auto ¶m : model.Parameters()) { param->set_requires_grad(false); } + } + // 同步 host 输入到目标设备并前向推理 + x = std::make_shared(x_tensor.To(device)); + auto outputs = model.Forward({x}); + auto logits = outputs[0]; + auto logits_cpu = logits->To(Device(DeviceType::kCPU, 0)); + const float *logits_data = static_cast(logits_cpu.DataPtr()); + const int64_t vocab_size = logits->Dims()[2]; + // 恢复参数梯度(后续训练仍需要建图反向) + if (t == text_length - 1) { + for (auto ¶m : model.Parameters()) { param->set_requires_grad(true); } + } + + // 生成语义(对齐 llm.c):取位置 t-1 的分布预测位置 t 的 token,每 batch 独立采样写回 + std::vector probs(static_cast(vocab_size)); + for (uint32_t b = 0; b < batch_size; ++b) { + const float *logits_at_t = logits_data + (b * sequence_length + t - 1) * vocab_size; + // softmax(数值稳定:先减最大值) + float max_logit = logits_at_t[0]; + for (int64_t i = 1; i < vocab_size; ++i) { + if (logits_at_t[i] > max_logit) { + max_logit = logits_at_t[i]; + } + } + float sum_exp = 0.0f; + for (int64_t i = 0; i < vocab_size; ++i) { + probs[i] = std::exp(logits_at_t[i] - max_logit); + sum_exp += probs[i]; + } + for (int64_t i = 0; i < vocab_size; ++i) { + probs[i] /= sum_exp; + } + + static uint64_t rng_state = kRngState; + const int next_token = SampleMult(probs.data(), static_cast(vocab_size), RandomF32(rng_state)); + x_buff[b * sequence_length + t] = next_token; + std::cout << Decode(next_token); + } } std::cout << std::endl; } From ddbda441ee3d3d90ead2f01e3c53dad093286209 Mon Sep 17 00:00:00 2001 From: accelerator-llc Date: Tue, 11 Aug 2026 19:10:58 +0800 Subject: [PATCH 10/12] =?UTF-8?q?=E4=BD=9C=E4=B8=9A6-CR=E5=8A=A0=E5=9B=BA+?= =?UTF-8?q?=E6=B3=A8=E9=87=8A=E6=B6=A6=E8=89=B2=EF=BC=9A=E6=95=B0=E6=8D=AE?= =?UTF-8?q?=E6=96=87=E4=BB=B6=E8=AF=BB=E5=8F=96=E5=AE=8C=E6=95=B4=E6=80=A7?= =?UTF-8?q?=E6=A0=A1=E9=AA=8C=E3=80=81Size=E8=BE=B9=E7=95=8C=E4=B8=8Eopera?= =?UTF-8?q?tor[]=E5=A5=91=E7=BA=A6=E4=B8=80=E8=87=B4=E3=80=81=E7=94=9F?= =?UTF-8?q?=E6=88=90=E9=9A=8F=E6=9C=BA=E7=A7=8D=E5=AD=90=E8=A7=84=E9=81=BF?= =?UTF-8?q?=E8=87=AA=E5=88=9D=E5=A7=8B=E5=8C=96UB=EF=BC=9B=E6=B8=85?= =?UTF-8?q?=E7=90=86=E5=86=85=E9=83=A8=E6=A0=87=E8=AE=B0=E5=B9=B6=E4=BF=AE?= =?UTF-8?q?=E6=AD=A3=E8=BF=87=E6=97=B6=E6=B3=A8=E9=87=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- example/common/tiny_shakespeare_dataset.cc | 15 ++++++++++---- example/common/tokenizer.cc | 11 +++++++--- infini_train/include/dispatcher.h | 2 +- .../src/kernels/cpu/accumulate_grad.cc | 6 +++--- infini_train/src/kernels/cpu/linear.cc | 8 ++++---- .../src/kernels/cuda/accumulate_grad.cu | 4 ++-- .../src/kernels/cuda/cross_entropy.cu | 3 ++- infini_train/src/kernels/cuda/linear.cu | 20 +++++++++---------- infini_train/src/kernels/cuda/reduction.cu | 3 ++- infini_train/src/kernels/cuda/softmax.cu | 3 ++- infini_train/src/tensor.cc | 12 +++++------ 11 files changed, 51 insertions(+), 36 deletions(-) diff --git a/example/common/tiny_shakespeare_dataset.cc b/example/common/tiny_shakespeare_dataset.cc index e07dc6e..aadbe78 100644 --- a/example/common/tiny_shakespeare_dataset.cc +++ b/example/common/tiny_shakespeare_dataset.cc @@ -65,17 +65,22 @@ TinyShakespeareFile ReadTinyShakespeareFile(const std::string &path, size_t sequ CHECK(ifs.is_open()) << "Failed to open file: " << path; auto header = ReadSeveralBytesFromIfstream(1024, &ifs); + CHECK_EQ(ifs.gcount(), 1024) << "Truncated header in file: " << path; const int magic = BytesToType(header, 0); const int num_toks = BytesToType(header, 8); CHECK(kTypeMap.contains(magic)) << "Unsupported magic number: " << magic; + CHECK_GE(num_toks, 0) << "Invalid num_toks in file: " << path; TinyShakespeareFile file; file.type = kTypeMap.at(magic); const size_t token_size = kTypeToSize.at(file.type); // 读 token 流并转为 int64 张量:CrossEntropy 的 target 要求 int64, - // 且 operator[] 中 y 的偏移 sizeof(int64_t) = 8 字节恰为 1 个 int64 token(右移 1 token 语义) - auto token_bytes = ReadSeveralBytesFromIfstream(static_cast(num_toks) * token_size, &ifs); + // 且 operator[] 中 y 相对 x 偏移 sizeof(int64_t) = 8 字节,恰为一个 int64 token(预测下一 token) + const size_t num_tok_bytes = static_cast(num_toks) * token_size; + auto token_bytes = ReadSeveralBytesFromIfstream(num_tok_bytes, &ifs); + // 读取完整性校验(损坏/截断文件下避免短向量导致的越界读) + CHECK_EQ(static_cast(ifs.gcount()), num_tok_bytes) << "Truncated token data in file: " << path; const size_t num_samples = static_cast(num_toks) / sequence_length; file.dims = {static_cast(num_samples), static_cast(sequence_length)}; file.tensor = infini_train::Tensor(file.dims, DataType::kINT64); @@ -92,7 +97,9 @@ TinyShakespeareDataset::TinyShakespeareDataset(const std::string &filepath, size : text_file_(ReadTinyShakespeareFile(filepath, sequence_length)), sequence_length_(sequence_length), sequence_size_in_bytes_(sequence_length * sizeof(int64_t)), - num_samples_(text_file_.dims[0]) { + // 最后一个序列缺"下一 token"作标签,operator[] 以 CHECK_LT(idx, dims[0]-1) 限定可访问边界, + // 故 Size 报告可访问样本数 = dims[0]-1(空数据集防护为 0),与 operator[] 的契约一致 + num_samples_(text_file_.dims[0] > 0 ? text_file_.dims[0] - 1 : 0) { // =================================== 作业 =================================== // TODO:初始化数据集实例 // HINT: 调用ReadTinyShakespeareFile加载数据文件 @@ -103,7 +110,7 @@ std::pair, std::shared_ptr dims = std::vector(text_file_.dims.begin() + 1, text_file_.dims.end()); - // x: (seq_len), y: (seq_len) -> stack -> (bs, seq_len) (bs, seq_len) + // x/y 与 text_file_.tensor 共享 buffer:x 取第 idx 个序列,y 偏移一个 token(预测下一 token) return {std::make_shared(text_file_.tensor, idx * sequence_size_in_bytes_, dims), std::make_shared(text_file_.tensor, idx * sequence_size_in_bytes_ + sizeof(int64_t), dims)}; diff --git a/example/common/tokenizer.cc b/example/common/tokenizer.cc index c9ee6b9..0d6776e 100644 --- a/example/common/tokenizer.cc +++ b/example/common/tokenizer.cc @@ -109,7 +109,7 @@ void Tokenizer::GenerateText(infini_train::nn::Module &model, uint32_t batch_siz uint32_t text_length, Device device) const { std::vector dims; dims.assign({batch_size, sequence_length}); - // x_tensor (FLAGS_batch_size, FLAGS_sequence_length) eq:(4, 64) + // x_tensor 形状为 (batch_size, sequence_length) infini_train::Tensor x_tensor = infini_train::Tensor(dims, DataType::kINT64); int64_t *x_buff = static_cast(x_tensor.DataPtr()); for (int i = 0; i < batch_size * sequence_length; ++i) { x_buff[i] = eot_token_; } @@ -129,7 +129,10 @@ void Tokenizer::GenerateText(infini_train::nn::Module &model, uint32_t batch_siz HINT:调用model.Forward推理获取logits,根据推理结果进行随机采样,调用Decode获取文本结果 ===================================== 作业 ===================================== */ // 生成场景无 Backward:临时禁用参数梯度使前向不建 autograd 图(Function 即时释放), - // 避免算子 saved_tensors_ 的循环引用在无 Backward 场景下导致显存逐步入泄漏 + // 避免算子 saved_tensors_ 的循环引用在无 Backward 场景下导致显存逐步泄漏。 + // 副作用说明:本方法(const 签名)临时修改模型参数的 requires_grad 状态——生成区间禁用、 + // 末步恢复为 true;若生成中途以异常中断,requires_grad 将残留为 false,需重新调用 + // set_requires_grad(true) 恢复训练(本框架错误处理为 CHECK 快速失败,正常路径不触发) if (t == prompt_len) { for (auto ¶m : model.Parameters()) { param->set_requires_grad(false); } } @@ -165,7 +168,9 @@ void Tokenizer::GenerateText(infini_train::nn::Module &model, uint32_t batch_siz probs[i] /= sum_exp; } - static uint64_t rng_state = kRngState; + // 函数开头声明了与命名空间常量同名的局部变量 `kRngState = kRngState;`(自初始化,未定义行为), + // 此处显式引用命名空间常量 kRngState(=1337,与 llm.c 固定种子一致)保证生成可复现 + static uint64_t rng_state = infini_train::kRngState; const int next_token = SampleMult(probs.data(), static_cast(vocab_size), RandomF32(rng_state)); x_buff[b * sequence_length + t] = next_token; std::cout << Decode(next_token); diff --git a/infini_train/include/dispatcher.h b/infini_train/include/dispatcher.h index a9de087..b3f556e 100644 --- a/infini_train/include/dispatcher.h +++ b/infini_train/include/dispatcher.h @@ -50,7 +50,7 @@ class Dispatcher { // 功能描述:将kernel函数与设备类型、名称绑定 // =================================== 作业 =================================== CHECK(!key_to_kernel_map_.contains(key)) - << "Kernel already registered: " << key.second << " on device: " << static_cast(key.first); // Fix CR#L52 + << "Kernel already registered: " << key.second << " on device: " << static_cast(key.first); key_to_kernel_map_.emplace(key, KernelFunction(std::forward(kernel))); } diff --git a/infini_train/src/kernels/cpu/accumulate_grad.cc b/infini_train/src/kernels/cpu/accumulate_grad.cc index 7263f87..a2d5e3d 100644 --- a/infini_train/src/kernels/cpu/accumulate_grad.cc +++ b/infini_train/src/kernels/cpu/accumulate_grad.cc @@ -1,4 +1,4 @@ -#include // Fix CR#L1-L5 +#include #include #include @@ -21,10 +21,10 @@ void AdamAccumulateGrad(const std::shared_ptr &grad, const std::shared_p // =================================== 作业 =================================== // 标准 Adam(含偏差校正):m = β1*m + (1-β1)*g,v = β2*v + (1-β2)*g², // param -= lr * m̂ / (√v̂ + eps),其中 m̂ = m/(1-β1^t),v̂ = v/(1-β2^t),t 从 1 开始 - CHECK_EQ(grad->NumElements(), param->NumElements()); // Fix CR#L23-L27:防直接调用 kernel 时越界读写 + CHECK_EQ(grad->NumElements(), param->NumElements()); // 校验各张量元素数一致,防止直接调用时越界读写 CHECK_EQ(m->NumElements(), param->NumElements()); CHECK_EQ(v->NumElements(), param->NumElements()); - // 偏差校正因子仅依赖步数,与元素无关,提升到循环外计算一次(double 对齐 PyTorch 标量路径的双精度惯例) // Fix CR#L31-L32 + // 偏差校正因子仅依赖步数,与元素无关,提升到循环外计算一次(double 对齐 PyTorch 标量路径的双精度惯例) const double beta1_t = std::pow(static_cast(beta1), static_cast(t)); const double beta2_t = std::pow(static_cast(beta2), static_cast(t)); for (int64_t idx = 0; idx < param->NumElements(); ++idx) { diff --git a/infini_train/src/kernels/cpu/linear.cc b/infini_train/src/kernels/cpu/linear.cc index deb6abe..6d98a02 100644 --- a/infini_train/src/kernels/cpu/linear.cc +++ b/infini_train/src/kernels/cpu/linear.cc @@ -21,7 +21,7 @@ std::shared_ptr MatmulForward(const std::shared_ptr &input, cons CHECK_EQ(input_dims.size(), other_dims.size()); // input[batch..., rows, in_features] × other[batch..., in_features, out_features] -> output[batch..., rows, out_features] - // 逐 batch 严格相等(batch 维不支持 torch.matmul 的广播语义) // Fix CR#L30-L33 + // 逐 batch 严格相等(batch 维不支持 torch.matmul 的广播语义) const int64_t batch_ndim = input_dims.size() - 2; const int64_t batch = std::accumulate(input_dims.begin(), input_dims.begin() + batch_ndim, 1, std::multiplies{}); @@ -40,7 +40,7 @@ std::shared_ptr MatmulForward(const std::shared_ptr &input, cons const float *input_data = static_cast(input->DataPtr()); const float *other_data = static_cast(other->DataPtr()); float *output_data = static_cast(output->DataPtr()); - // 循环次序 r->k->c(内层 c):other 与 output 沿连续方向访问(缓存友好),output 先清零再累积 // Fix CR#L42-L53 + // 循环次序 r->k->c(内层 c):other 与 output 沿连续方向访问(缓存友好),output 先清零再累积 const int64_t output_size = batch * rows * out_features; for (int64_t idx = 0; idx < output_size; ++idx) { output_data[idx] = 0.0f; @@ -73,7 +73,7 @@ MatmulBackward(const std::shared_ptr &input, const std::shared_ptr{}); @@ -98,7 +98,7 @@ MatmulBackward(const std::shared_ptr &input, const std::shared_ptr(grad_input->DataPtr()); float *grad_other_data = static_cast(grad_other->DataPtr()); // grad_other[b][k][c] = Σ_r input[b][r][k] * grad_output[b][r][c] - // 循环次序 k->r->c(内层 c):grad_output 与 grad_other 沿连续方向访问(缓存友好),grad_other 先清零 // Fix CR#L94-L115 + // 循环次序 k->r->c(内层 c):grad_output 与 grad_other 沿连续方向访问(缓存友好),grad_other 先清零 const int64_t grad_other_size = batch * in_features * out_features; for (int64_t idx = 0; idx < grad_other_size; ++idx) { grad_other_data[idx] = 0.0f; diff --git a/infini_train/src/kernels/cuda/accumulate_grad.cu b/infini_train/src/kernels/cuda/accumulate_grad.cu index b53a06e..7ff778e 100644 --- a/infini_train/src/kernels/cuda/accumulate_grad.cu +++ b/infini_train/src/kernels/cuda/accumulate_grad.cu @@ -58,9 +58,9 @@ void AdamAccumulateGrad(const std::shared_ptr &grad, const std::shared_p const double beta2_t = std::pow(static_cast(beta2), static_cast(t)); size_t num_elements = param->NumElements(); - // Fix CR#L29-L30:32 位索引范围防护(超过 int 范围时快速失败,避免后半元素静默不处理) + // 32 位索引范围防护(超过 int 范围时快速失败,避免后半元素静默不处理) CHECK_LE(num_elements, static_cast(std::numeric_limits::max())); - // Fix CR#L25-L28:kernel 的 restrict 承诺要求写目标(param/m/v)与读源(grad)互不重叠(原地调用属未定义行为) + // kernel 的 restrict 承诺要求写目标(param/m/v)与读源(grad)互不重叠(原地调用属未定义行为) const float *grad_ptr = static_cast(grad->DataPtr()); float *param_ptr = static_cast(param->DataPtr()); float *m_ptr = static_cast(m->DataPtr()); diff --git a/infini_train/src/kernels/cuda/cross_entropy.cu b/infini_train/src/kernels/cuda/cross_entropy.cu index 16cb013..aad3ddb 100644 --- a/infini_train/src/kernels/cuda/cross_entropy.cu +++ b/infini_train/src/kernels/cuda/cross_entropy.cu @@ -1,4 +1,5 @@ -// 注意:libcu++ 头(cuda/std/*)必须在任何标准库头之前包含(CUDA 13 自带 CUB 3.x 触发此分支)。 +// 注意:libcu++ 头(cuda/std/*)必须先于任何标准库头包含:CUDA 13 自带的 CUB 3.x 满足下方版本条件, +// 需要 ,若标准库头先被包含会导致编译错误。 #if defined(CUB_VERSION) && CUB_VERSION >= 200800 #include #endif diff --git a/infini_train/src/kernels/cuda/linear.cu b/infini_train/src/kernels/cuda/linear.cu index 45634b6..499c5ee 100644 --- a/infini_train/src/kernels/cuda/linear.cu +++ b/infini_train/src/kernels/cuda/linear.cu @@ -23,7 +23,7 @@ namespace infini_train::kernels::cuda { } \ } while (0) -// restrict 承诺:写目标(output)必须与读源(input/other)不重叠,原地调用属未定义行为 // Fix CR#L26-L28 +// restrict 承诺:写目标(output)必须与读源(input/other)不重叠,原地调用属未定义行为 __global__ void MatmulForwardKernel(const float *__restrict__ input, const float *__restrict__ other, float *__restrict__ output, int rows, int in_features, int out_features, int total) { @@ -37,7 +37,7 @@ __global__ void MatmulForwardKernel(const float *__restrict__ input, const float int batch = tmp / rows; // 每个线程串行累加 k(k 升序,固定累加顺序保证 GPU 侧运行位级确定; - // 与 CPU 版在容差内一致——nvcc 默认 FMA 融合(--fmad=true),存在 1 ULP 级差异) // Fix CR#L38 + // 与 CPU 版在容差内一致——nvcc 默认 FMA 融合(--fmad=true),存在 1 ULP 级差异) float sum = 0.0f; const float *input_row = input + (batch * rows + row) * in_features; const float *other_base = other + (batch * in_features) * out_features + col; @@ -69,7 +69,7 @@ std::shared_ptr MatmulForward(const std::shared_ptr &input, cons CHECK_EQ(input_dims[dim], other_dims[dim]); } CHECK_EQ(in_features, *(other_dims.rbegin() + 1)); - CHECK_EQ(static_cast(input->GetDevice().Type()), static_cast(other->GetDevice().Type())); // Fix CR#L160-L161 + CHECK_EQ(static_cast(input->GetDevice().Type()), static_cast(other->GetDevice().Type())); auto output_dims = input_dims; *output_dims.rbegin() = out_features; @@ -77,7 +77,7 @@ std::shared_ptr MatmulForward(const std::shared_ptr &input, cons // 一元素一线程 + 边界检查(网格按 CEIL_DIV 划分,与官方 kernel 风格一致) const int64_t total = batch * rows * out_features; - // Fix CR#L76-L82:32 位索引范围防护(total 超出 int 范围时快速失败,避免溢出为负导致静默空输出) + // 32 位索引范围防护(total 超出 int 范围时快速失败,避免溢出为负导致静默空输出) CHECK_LE(total, std::numeric_limits::max()); int threads_per_block = 256; int num_blocks = static_cast((total + threads_per_block - 1) / threads_per_block); @@ -88,7 +88,7 @@ std::shared_ptr MatmulForward(const std::shared_ptr &input, cons return output; } -// restrict 承诺:写目标(grad_input)必须与读源(grad_output/other)不重叠,原地调用属未定义行为 // Fix CR#L26-L28 +// restrict 承诺:写目标(grad_input)必须与读源(grad_output/other)不重叠,原地调用属未定义行为 __global__ void MatmulBackwardGradInputKernel(const float *__restrict__ grad_output, const float *__restrict__ other, float *__restrict__ grad_input, int rows, int in_features, int out_features, int total) { @@ -104,7 +104,7 @@ __global__ void MatmulBackwardGradInputKernel(const float *__restrict__ grad_out // grad_input[batch][row][k] = Σ_c grad_output[batch][row][c] * other[batch][k][c] // c 串行累加保证 GPU 侧运行位级确定;warp 内相邻线程(k 连续)读 other 为按列访问(非 coalesced)—— // A×B^T 结构在无共享内存的 naive 形态下两操作数无法同时行主序访问,当前映射已保证输出写 coalesced - // 与 grad_output 广播读,教学定位保持现状 // Fix CR#L86-L106, L98 + // 与 grad_output 广播读,教学定位保持现状 float sum = 0.0f; const float *grad_output_row = grad_output + (batch * rows + row) * out_features; const float *other_base = other + (batch * in_features + k) * out_features; @@ -114,7 +114,7 @@ __global__ void MatmulBackwardGradInputKernel(const float *__restrict__ grad_out grad_input[idx] = sum; } -// restrict 承诺:写目标(grad_other)必须与读源(input/grad_output)不重叠,原地调用属未定义行为 // Fix CR#L26-L28 +// restrict 承诺:写目标(grad_other)必须与读源(input/grad_output)不重叠,原地调用属未定义行为 __global__ void MatmulBackwardGradOtherKernel(const float *__restrict__ input, const float *__restrict__ grad_output, float *__restrict__ grad_other, int rows, int in_features, int out_features, int total) { @@ -128,7 +128,7 @@ __global__ void MatmulBackwardGradOtherKernel(const float *__restrict__ input, c int batch = tmp / in_features; // grad_other[batch][k][col] = Σ_r input[batch][row][k] * grad_output[batch][row][col] - // r 串行累加保证 GPU 侧运行位级确定;warp 内相邻线程(col 连续)grad_output 与写均 coalesced // Fix CR#L120 + // r 串行累加保证 GPU 侧运行位级确定;warp 内相邻线程(col 连续)grad_output 与写均 coalesced float sum = 0.0f; const float *input_base = input + (batch * rows) * in_features + k; const float *grad_output_base = grad_output + (batch * rows) * out_features + col; @@ -163,7 +163,7 @@ MatmulBackward(const std::shared_ptr &input, const std::shared_ptr(input->GetDevice().Type()), static_cast(other->GetDevice().Type())); CHECK_EQ(static_cast(input->GetDevice().Type()), static_cast(grad_output->GetDevice().Type())); @@ -176,7 +176,7 @@ MatmulBackward(const std::shared_ptr &input, const std::shared_ptr::max()); int threads_per_block = 256; int num_blocks = static_cast((grad_input_total + threads_per_block - 1) / threads_per_block); diff --git a/infini_train/src/kernels/cuda/reduction.cu b/infini_train/src/kernels/cuda/reduction.cu index 047517e..d621123 100644 --- a/infini_train/src/kernels/cuda/reduction.cu +++ b/infini_train/src/kernels/cuda/reduction.cu @@ -1,4 +1,5 @@ -// 注意:libcu++ 头(cuda/std/*)必须在任何标准库头之前包含(CUDA 13 自带 CUB 3.x 触发此分支)。 +// 注意:libcu++ 头(cuda/std/*)必须先于任何标准库头包含:CUDA 13 自带的 CUB 3.x 满足下方版本条件, +// 需要 ,若标准库头先被包含会导致编译错误。 #if defined(CUB_VERSION) && CUB_VERSION >= 200800 #include #endif diff --git a/infini_train/src/kernels/cuda/softmax.cu b/infini_train/src/kernels/cuda/softmax.cu index 3121665..0148e0a 100644 --- a/infini_train/src/kernels/cuda/softmax.cu +++ b/infini_train/src/kernels/cuda/softmax.cu @@ -1,4 +1,5 @@ -// 注意:libcu++ 头(cuda/std/*)必须在任何标准库头之前包含(CUDA 13 自带 CUB 3.x 触发此分支)。 +// 注意:libcu++ 头(cuda/std/*)必须先于任何标准库头包含:CUDA 13 自带的 CUB 3.x 满足下方版本条件, +// 需要 ,若标准库头先被包含会导致编译错误。 #if defined(CUB_VERSION) && CUB_VERSION >= 200800 #include #endif diff --git a/infini_train/src/tensor.cc b/infini_train/src/tensor.cc index b07a188..e4d7a31 100644 --- a/infini_train/src/tensor.cc +++ b/infini_train/src/tensor.cc @@ -283,7 +283,7 @@ std::shared_ptr Tensor::Flatten(int64_t start, int64_t end) { // HINT: // =================================== 作业 =================================== if (dims_.empty()) { - // Fix CR#L285-L294:零维张量(标量)无维度可合并,返回原张量,对齐 PyTorch 0 维 flatten 语义 + // 零维张量(标量)无维度可合并,返回原张量(对齐 PyTorch 0 维 flatten 语义) return shared_from_this(); } if (start < 0) { @@ -383,13 +383,13 @@ void Tensor::Backward(std::shared_ptr gradient, bool retain_graph, bool // 功能描述:1. 计算当前张量对叶子节点的梯度 2. 支持多输出场景的梯度累加 // =================================== 作业 =================================== if (!gradient) { - // 无参调用(对应 PyTorch 标量输出 tensor.backward() 语义):默认梯度为全 1 // Fix CR#L381-L385 - // 注:非标量输出同样构造全 1 梯度(数学上等价于显式 ones 梯度),与 PyTorch 的标量检查不同, + // 无参调用(对应 PyTorch 标量输出 tensor.backward() 语义):默认梯度为全 1 + // 非标量输出同样构造全 1 梯度(数学上等价于显式 ones 梯度),与 PyTorch 的标量检查不同, // 属教学框架的宽容语义;默认梯度经 Fill kernel 按 float 填充,当前仅支持框架使用的 float32 路径。 gradient = std::make_shared(dims_, dtype_, GetDevice()); gradient->Fill(1.0f); } else { - // Fix CR#L386-L388:透传前校验梯度形状与设备一致,防止元素错位静默累加与越界写 + // 透传前校验梯度形状与设备一致,防止元素错位静默累加与越界写 CHECK(gradient->Dims() == dims_) << "Backward gradient shape mismatch"; CHECK_EQ(static_cast(gradient->GetDevice().Type()), static_cast(GetDevice().Type())) << "Backward gradient device mismatch"; @@ -397,8 +397,8 @@ void Tensor::Backward(std::shared_ptr gradient, bool retain_graph, bool if (grad_fn_) { grad_fn_->BackwardPartial(gradient, output_idx_); } - // 契约(与 PyTorch 语义差异说明):同一图仅可传播一次,重复传播为未定义行为(根因在官方引擎传播后 - // 不重置依赖计数,修改受限);retain_graph/create_graph 参数当前无效果;无梯度函数(叶子)调用为空操作。 + // 契约说明(与 PyTorch 语义的差异):同一计算图仅可传播一次,重复传播为未定义行为; + // retain_graph/create_graph 参数当前无效果;无梯度函数的张量(叶子)调用为空操作。 } void Tensor::ZeroGrad() { From 5a5d8ff3df6ede385553af18430c25bcbea2ad33 Mon Sep 17 00:00:00 2001 From: accelerator-llc Date: Tue, 11 Aug 2026 20:11:17 +0800 Subject: [PATCH 11/12] =?UTF-8?q?=E4=BF=AE=E5=A4=8Dtokenizer=E8=AF=8D?= =?UTF-8?q?=E8=A1=A8=E8=AF=BB=E5=8F=96=E6=A0=A1=E9=AA=8C=E3=80=81=E7=A9=BA?= =?UTF-8?q?=E6=95=B0=E6=8D=AE=E9=9B=86CHECK=E8=BE=B9=E7=95=8C=E3=80=81Adam?= =?UTF-8?q?=E6=AD=A5=E6=95=B0=E9=98=B2=E6=8A=A4=E4=B8=8EFlatten=E6=B3=A8?= =?UTF-8?q?=E9=87=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- example/common/tiny_shakespeare_dataset.cc | 3 ++- example/common/tokenizer.cc | 3 +++ infini_train/src/kernels/cpu/accumulate_grad.cc | 1 + infini_train/src/kernels/cuda/accumulate_grad.cu | 1 + infini_train/src/tensor.cc | 2 +- 5 files changed, 8 insertions(+), 2 deletions(-) diff --git a/example/common/tiny_shakespeare_dataset.cc b/example/common/tiny_shakespeare_dataset.cc index aadbe78..7c82a99 100644 --- a/example/common/tiny_shakespeare_dataset.cc +++ b/example/common/tiny_shakespeare_dataset.cc @@ -108,7 +108,8 @@ TinyShakespeareDataset::TinyShakespeareDataset(const std::string &filepath, size std::pair, std::shared_ptr> TinyShakespeareDataset::operator[](size_t idx) const { - CHECK_LT(idx, text_file_.dims[0] - 1); + // 以 num_samples_(可访问样本数,构造时已防护空数据集)限定边界,与 Size 契约一致 + CHECK_LT(idx, num_samples_); std::vector dims = std::vector(text_file_.dims.begin() + 1, text_file_.dims.end()); // x/y 与 text_file_.tensor 共享 buffer:x 取第 idx 个序列,y 偏移一个 token(预测下一 token) return {std::make_shared(text_file_.tensor, idx * sequence_size_in_bytes_, dims), diff --git a/example/common/tokenizer.cc b/example/common/tokenizer.cc index 0d6776e..4532347 100644 --- a/example/common/tokenizer.cc +++ b/example/common/tokenizer.cc @@ -82,6 +82,7 @@ Tokenizer::Tokenizer(const std::string &filepath) { CHECK(ifs.is_open()) << "Failed to open tokenizer file: " << filepath; auto header = ReadSeveralBytesFromIfstream(1024, &ifs); + CHECK_EQ(ifs.gcount(), 1024) << "Truncated header in file: " << filepath; magic_number_ = BytesToType(header, 0); vocab_size_ = BytesToType(header, 8); eot_token_ = BytesToType(header, 12); @@ -94,6 +95,8 @@ Tokenizer::Tokenizer(const std::string &filepath) { auto bytes = ReadSeveralBytesFromIfstream(len, &ifs); token_table_.emplace_back(reinterpret_cast(bytes.data()), len); } + // 词表读取完整性校验:任一项读取失败(文件截断)都会置位 failbit,快速失败而非静默加载垃圾词表 + CHECK(ifs) << "Truncated tokenizer vocab table in file: " << filepath; } std::string Tokenizer::Decode(uint32_t token_id) const { diff --git a/infini_train/src/kernels/cpu/accumulate_grad.cc b/infini_train/src/kernels/cpu/accumulate_grad.cc index a2d5e3d..ee56c8d 100644 --- a/infini_train/src/kernels/cpu/accumulate_grad.cc +++ b/infini_train/src/kernels/cpu/accumulate_grad.cc @@ -24,6 +24,7 @@ void AdamAccumulateGrad(const std::shared_ptr &grad, const std::shared_p CHECK_EQ(grad->NumElements(), param->NumElements()); // 校验各张量元素数一致,防止直接调用时越界读写 CHECK_EQ(m->NumElements(), param->NumElements()); CHECK_EQ(v->NumElements(), param->NumElements()); + CHECK_GE(t, 1); // Adam 偏差校正要求 t 从 1 开始(t=0 时 1-β^t=0,除零产生 NaN) // 偏差校正因子仅依赖步数,与元素无关,提升到循环外计算一次(double 对齐 PyTorch 标量路径的双精度惯例) const double beta1_t = std::pow(static_cast(beta1), static_cast(t)); const double beta2_t = std::pow(static_cast(beta2), static_cast(t)); diff --git a/infini_train/src/kernels/cuda/accumulate_grad.cu b/infini_train/src/kernels/cuda/accumulate_grad.cu index 7ff778e..390cd6a 100644 --- a/infini_train/src/kernels/cuda/accumulate_grad.cu +++ b/infini_train/src/kernels/cuda/accumulate_grad.cu @@ -53,6 +53,7 @@ void AdamAccumulateGrad(const std::shared_ptr &grad, const std::shared_p CHECK_EQ(grad->NumElements(), param->NumElements()); CHECK_EQ(m->NumElements(), param->NumElements()); CHECK_EQ(v->NumElements(), param->NumElements()); + CHECK_GE(t, 1); // Adam 偏差校正要求 t 从 1 开始(t=0 时 1-β^t=0,除零产生 NaN) // 偏差校正因子仅依赖步数,与元素无关,host 侧计算一次(double,与 CPU 版一致) const double beta1_t = std::pow(static_cast(beta1), static_cast(t)); const double beta2_t = std::pow(static_cast(beta2), static_cast(t)); diff --git a/infini_train/src/tensor.cc b/infini_train/src/tensor.cc index e4d7a31..bc906b7 100644 --- a/infini_train/src/tensor.cc +++ b/infini_train/src/tensor.cc @@ -283,7 +283,7 @@ std::shared_ptr Tensor::Flatten(int64_t start, int64_t end) { // HINT: // =================================== 作业 =================================== if (dims_.empty()) { - // 零维张量(标量)无维度可合并,返回原张量(对齐 PyTorch 0 维 flatten 语义) + // 零维张量(标量)无维度可合并,返回原张量(教学框架简化语义;PyTorch 现行为对 0 维 flatten 返回一维视图) return shared_from_this(); } if (start < 0) { From 1e184aceabdc90d46fe435c8ed9eafb41bdb8b30 Mon Sep 17 00:00:00 2001 From: accelerator-llc Date: Tue, 11 Aug 2026 20:54:27 +0800 Subject: [PATCH 12/12] =?UTF-8?q?=E4=BD=9C=E4=B8=9A=E6=8A=A5=E5=91=8A?= =?UTF-8?q?=E4=B8=8E=E6=B5=8B=E8=AF=95=E6=88=AA=E5=9B=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...34\344\270\232\346\212\245\345\221\212.md" | 513 ++++++++++++++++-- ...2\350\277\207\346\210\252\345\233\276.png" | Bin 0 -> 121420 bytes 2 files changed, 468 insertions(+), 45 deletions(-) create mode 100644 "docs/test\351\200\232\350\277\207\346\210\252\345\233\276.png" diff --git "a/docs/TinyInfiniTrain \344\275\234\344\270\232\346\212\245\345\221\212.md" "b/docs/TinyInfiniTrain \344\275\234\344\270\232\346\212\245\345\221\212.md" index bc23852..6d117e3 100644 --- "a/docs/TinyInfiniTrain \344\275\234\344\270\232\346\212\245\345\221\212.md" +++ "b/docs/TinyInfiniTrain \344\275\234\344\270\232\346\212\245\345\221\212.md" @@ -2,6 +2,11 @@ ## 一、test 通过截图 +![test通过截图](test通过截图.png) + +> 测试环境说明:RTX 5090(32GB)+ CUDA 13.3 运行时环境,`ctest` 全量 8/8 通过(test_gpt2 耗时约 63s,为 GPT-2 124M 训练 11 步 + 生成 64 token 的真实开销)。 +> 注:test_gpt2 的 logits 对比对运行环境敏感(cuBLAS 版本/GPU 架构与参考生成环境的数值轨迹差异,详见作业六"遇到问题"),本机 4060 + CUDA 13.3 下其余 7/8 全过、test_gpt2 因该环境性差异失败;在 5090 + CUDA 13.3 运行时环境下 8/8 全部通过。 + ## 二、作业步骤 > 将代码填入下面代码块中指定位置,并详细描述完成该作业的解决思路和遇到的问题。 @@ -18,25 +23,37 @@ std::vector> Neg::Forward(const std::vector> &input_tensors) { // =================================== 作业 =================================== // TODO:通过Dispatcher获取设备专属kernel,对输入张量进行取反操作 - // HINT: 依赖test_dispatcher,kernel实现已给出 + // NOTES: 依赖test_dispatcher,Neg kernel实现已给出 // =================================== 作业 =================================== + CHECK_EQ(input_tensors.size(), 1); + const auto &input = input_tensors[0]; + + auto device = input->GetDevice().Type(); + auto kernel = Dispatcher::Instance().GetKernel({device, "NegForward"}); + return {kernel.Call>(input)}; } std::vector> Neg::Backward(const std::vector> &grad_outputs) { // =================================== 作业 =================================== // TODO:通过Dispatcher获取设备专属的反向传播kernel,计算梯度 - // HINT: 依赖test_dispatcher,kernel实现已给出 + // NOTES: 依赖test_dispatcher,Neg的kernel实现已给出 // =================================== 作业 =================================== + CHECK_EQ(grad_outputs.size(), 1); + const auto &grad_output = grad_outputs[0]; + + auto device = grad_output->GetDevice().Type(); + auto kernel = Dispatcher::Instance().GetKernel({device, "NegBackward"}); + return {kernel.Call>(grad_output)}; } ``` #### 解决思路 - +Neg 的 Forward/Backward 采用同文件 `Reciprocal` 的官方实现模式:通过 `Dispatcher::Instance().GetKernel({device, "NegForward"})` 获取设备专属 kernel,再 `Call>(input)` 调用。设备类型取自输入张量自身,天然支持 CPU/CUDA 双设备。Backward 中利用 Neg 的导数恒为 -1(与输入值无关)的特性:`d(-x)/dx = -1`,梯度即 `-grad_output`,因此无需保存输入张量(与 `Neg` 类未声明 `SetupContext` 的类设计一致,测试亦直接调用 `Backward` 未先调用 `SetupContext`)。 #### 遇到问题 - +本作业实现本身无难点;前置依赖是作业五(Dispatcher 注册机制)——在作业五完成前,`REGISTER_KERNEL` 宏为空导致所有 kernel 注册不进去,所有测试报 `Kernel not found`。作业五完成后本作业立即通过。 ### 作业二:实现矩阵乘法 @@ -49,21 +66,119 @@ std::vector> Neg::Backward(const std::vector MatmulForward(const std::shared_ptr &input, const std::shared_ptr &other) { - // =================================== 作业 =================================== - // TODO:实现CPU上的矩阵乘法前向计算 - // REF: - // =================================== 作业 =================================== +std::shared_ptr MatmulForward(const std::shared_ptr &input, const std::shared_ptr &other) { + // =================================== 作业 =================================== + // TODO:实现CPU上的矩阵乘法前向计算 + // REF: + // =================================== 作业 =================================== + const auto &input_dims = input->Dims(); + const auto &other_dims = other->Dims(); + CHECK_GE(input_dims.size(), 2); + CHECK_EQ(input_dims.size(), other_dims.size()); + + // input[batch..., rows, in_features] × other[batch..., in_features, out_features] -> output[batch..., rows, out_features] + // 逐 batch 严格相等(batch 维不支持 torch.matmul 的广播语义) + const int64_t batch_ndim = input_dims.size() - 2; + const int64_t batch = + std::accumulate(input_dims.begin(), input_dims.begin() + batch_ndim, 1, std::multiplies{}); + const int64_t rows = input_dims[batch_ndim]; + const int64_t in_features = *input_dims.rbegin(); + const int64_t out_features = *other_dims.rbegin(); + for (int64_t dim = 0; dim < batch_ndim; ++dim) { + CHECK_EQ(input_dims[dim], other_dims[dim]); + } + CHECK_EQ(in_features, *(other_dims.rbegin() + 1)); + + auto output_dims = input_dims; + *output_dims.rbegin() = out_features; + auto output = std::make_shared(output_dims, DataType::kFLOAT32); + + const float *input_data = static_cast(input->DataPtr()); + const float *other_data = static_cast(other->DataPtr()); + float *output_data = static_cast(output->DataPtr()); + // 循环次序 r->k->c(内层 c):other 与 output 沿连续方向访问(缓存友好),output 先清零再累积 + const int64_t output_size = batch * rows * out_features; + for (int64_t idx = 0; idx < output_size; ++idx) { + output_data[idx] = 0.0f; } + for (int64_t b = 0; b < batch; ++b) { + for (int64_t r = 0; r < rows; ++r) { + for (int64_t k = 0; k < in_features; ++k) { + const float a = input_data[(b * rows + r) * in_features + k]; + for (int64_t c = 0; c < out_features; ++c) { + output_data[(b * rows + r) * out_features + c] + += a * other_data[(b * in_features + k) * out_features + c]; + } + } + } + } + return output; +} - std::tuple, std::shared_ptr> - MatmulBackward(const std::shared_ptr &input, const std::shared_ptr &other, - const std::shared_ptr &grad_output) { - // =================================== 作业 =================================== - // TODO:实现CPU上的矩阵乘法反向传播 - // REF: - // =================================== 作业 =================================== +std::tuple, std::shared_ptr> +MatmulBackward(const std::shared_ptr &input, const std::shared_ptr &other, + const std::shared_ptr &grad_output) { + // =================================== 作业 =================================== + // TODO:实现CPU上的矩阵乘法反向传播 + // REF: + // =================================== 作业 =================================== + // grad_input = grad_output × other^T,grad_other = input^T × grad_output + // 逐 batch 严格相等(batch 维不支持 torch.matmul 的广播语义) + const auto &input_dims = input->Dims(); + const auto &other_dims = other->Dims(); + const auto &grad_output_dims = grad_output->Dims(); + CHECK_GE(input_dims.size(), 2); + CHECK_EQ(input_dims.size(), other_dims.size()); + + const int64_t batch_ndim = input_dims.size() - 2; + const int64_t batch = + std::accumulate(input_dims.begin(), input_dims.begin() + batch_ndim, 1, std::multiplies{}); + const int64_t rows = input_dims[batch_ndim]; + const int64_t in_features = *input_dims.rbegin(); + const int64_t out_features = *other_dims.rbegin(); + + auto output_dims = input_dims; + *output_dims.rbegin() = out_features; + CHECK(grad_output_dims == output_dims); + + auto grad_input = std::make_shared(input_dims, DataType::kFLOAT32); + auto grad_other = std::make_shared(other_dims, DataType::kFLOAT32); + + const float *input_data = static_cast(input->DataPtr()); + const float *other_data = static_cast(other->DataPtr()); + const float *grad_output_data = static_cast(grad_output->DataPtr()); + float *grad_input_data = static_cast(grad_input->DataPtr()); + float *grad_other_data = static_cast(grad_other->DataPtr()); + // grad_other[b][k][c] = Σ_r input[b][r][k] * grad_output[b][r][c] + // 循环次序 k->r->c(内层 c):grad_output 与 grad_other 沿连续方向访问(缓存友好),grad_other 先清零 + const int64_t grad_other_size = batch * in_features * out_features; + for (int64_t idx = 0; idx < grad_other_size; ++idx) { + grad_other_data[idx] = 0.0f; + } + for (int64_t b = 0; b < batch; ++b) { + // grad_input[b][r][k] = Σ_c grad_output[b][r][c] * other[b][k][c](c 最内层,两操作数连续访问) + for (int64_t r = 0; r < rows; ++r) { + for (int64_t k = 0; k < in_features; ++k) { + float sum = 0.0f; + for (int64_t c = 0; c < out_features; ++c) { + sum += grad_output_data[(b * rows + r) * out_features + c] + * other_data[(b * in_features + k) * out_features + c]; + } + grad_input_data[(b * rows + r) * in_features + k] = sum; + } + } + for (int64_t k = 0; k < in_features; ++k) { + for (int64_t r = 0; r < rows; ++r) { + const float a = input_data[(b * rows + r) * in_features + k]; + for (int64_t c = 0; c < out_features; ++c) { + grad_other_data[(b * in_features + k) * out_features + c] + += a * grad_output_data[(b * rows + r) * out_features + c]; + } + } + } } + return {grad_input, grad_other}; +} ``` #### CUDA实现 @@ -73,30 +188,113 @@ std::vector> Neg::Backward(const std::vector MatmulForward(const std::shared_ptr &input, const std::shared_ptr &other) { - // =================================== 作业 =================================== - // TODO:实现CUDA上的矩阵乘法前向计算 - // REF: - // =================================== 作业 =================================== +std::shared_ptr MatmulForward(const std::shared_ptr &input, const std::shared_ptr &other) { + // =================================== 作业 =================================== + // TODO:实现CUDA上的矩阵乘法前向计算 + // REF: + // =================================== 作业 =================================== + const auto &input_dims = input->Dims(); + const auto &other_dims = other->Dims(); + CHECK_GE(input_dims.size(), 2); + CHECK_EQ(input_dims.size(), other_dims.size()); + + // input[batch..., rows, in_features] × other[batch..., in_features, out_features] -> output[batch..., rows, out_features] + // 逐 batch 严格相等(batch 维不支持 torch.matmul 的广播语义),语义与 CPU 版一致 + const int64_t batch_ndim = input_dims.size() - 2; + const int64_t batch = + std::accumulate(input_dims.begin(), input_dims.begin() + batch_ndim, 1, std::multiplies{}); + const int64_t rows = input_dims[batch_ndim]; + const int64_t in_features = *input_dims.rbegin(); + const int64_t out_features = *other_dims.rbegin(); + for (int64_t dim = 0; dim < batch_ndim; ++dim) { + CHECK_EQ(input_dims[dim], other_dims[dim]); } + CHECK_EQ(in_features, *(other_dims.rbegin() + 1)); + CHECK_EQ(static_cast(input->GetDevice().Type()), static_cast(other->GetDevice().Type())); + + auto output_dims = input_dims; + *output_dims.rbegin() = out_features; + auto output = std::make_shared(output_dims, DataType::kFLOAT32, input->GetDevice()); + + // 一元素一线程 + 边界检查(网格按 CEIL_DIV 划分,与官方 kernel 风格一致) + const int64_t total = batch * rows * out_features; + // 32 位索引范围防护(total 超出 int 范围时快速失败,避免溢出为负导致静默空输出) + CHECK_LE(total, std::numeric_limits::max()); + int threads_per_block = 256; + int num_blocks = static_cast((total + threads_per_block - 1) / threads_per_block); + MatmulForwardKernel<<>>( + static_cast(input->DataPtr()), static_cast(other->DataPtr()), + static_cast(output->DataPtr()), static_cast(rows), static_cast(in_features), + static_cast(out_features), static_cast(total)); + return output; +} - std::tuple, std::shared_ptr> - MatmulBackward(const std::shared_ptr &input, const std::shared_ptr &other, - const std::shared_ptr &grad_output) { - // =================================== 作业 =================================== - // TODO:实现CUDA上的矩阵乘法反向传播 - // REF: - // =================================== 作业 =================================== +std::tuple, std::shared_ptr> +MatmulBackward(const std::shared_ptr &input, const std::shared_ptr &other, + const std::shared_ptr &grad_output) { + // =================================== 作业 =================================== + // TODO:实现CUDA上的矩阵乘法反向传播 + // REF: + // =================================== 作业 =================================== + const auto &input_dims = input->Dims(); + const auto &other_dims = other->Dims(); + const auto &grad_output_dims = grad_output->Dims(); + CHECK_GE(input_dims.size(), 2); + CHECK_EQ(input_dims.size(), other_dims.size()); + + // grad_input = grad_output × other^T,grad_other = input^T × grad_output + const int64_t batch_ndim = input_dims.size() - 2; + const int64_t batch = + std::accumulate(input_dims.begin(), input_dims.begin() + batch_ndim, 1, std::multiplies{}); + const int64_t rows = input_dims[batch_ndim]; + const int64_t in_features = *input_dims.rbegin(); + const int64_t out_features = *other_dims.rbegin(); + for (int64_t dim = 0; dim < batch_ndim; ++dim) { + CHECK_EQ(input_dims[dim], other_dims[dim]); } + CHECK_EQ(in_features, *(other_dims.rbegin() + 1)); + // 设备一致性校验(跨设备误用快速失败而非异步运行时错误) + CHECK_EQ(static_cast(input->GetDevice().Type()), static_cast(other->GetDevice().Type())); + CHECK_EQ(static_cast(input->GetDevice().Type()), static_cast(grad_output->GetDevice().Type())); + + auto output_dims = input_dims; + *output_dims.rbegin() = out_features; + CHECK(grad_output_dims == output_dims); + + auto grad_input = std::make_shared(input_dims, DataType::kFLOAT32, grad_output->GetDevice()); + auto grad_other = std::make_shared(other_dims, DataType::kFLOAT32, grad_output->GetDevice()); + + // 两个 kernel 均为一元素一线程 + 边界检查,串行内层循环保证 GPU 侧运行位级确定 + const int64_t grad_input_total = batch * rows * in_features; + CHECK_LE(grad_input_total, std::numeric_limits::max()); + int threads_per_block = 256; + int num_blocks = static_cast((grad_input_total + threads_per_block - 1) / threads_per_block); + MatmulBackwardGradInputKernel<<>>( + static_cast(grad_output->DataPtr()), static_cast(other->DataPtr()), + static_cast(grad_input->DataPtr()), static_cast(rows), static_cast(in_features), + static_cast(out_features), static_cast(grad_input_total)); + + const int64_t grad_other_total = batch * in_features * out_features; + CHECK_LE(grad_other_total, std::numeric_limits::max()); + num_blocks = static_cast((grad_other_total + threads_per_block - 1) / threads_per_block); + MatmulBackwardGradOtherKernel<<>>( + static_cast(input->DataPtr()), static_cast(grad_output->DataPtr()), + static_cast(grad_other->DataPtr()), static_cast(rows), static_cast(in_features), + static_cast(out_features), static_cast(grad_other_total)); + return {grad_input, grad_other}; +} ``` #### 解决思路 +**CPU 实现**:手写三重循环矩阵乘,支持 2D 与 3D batch(逐 batch 独立计算,索引含 batch 偏移)。维度解析仿同文件官方 `LinearForward`(batch = 除末两维外的前缀乘积,rows/in_features/out_features 取法一致),并做维度校验(维度数相等、batch 逐维相等、in_features 匹配)。循环次序采用 r→k→c(内层 c),使 other 与输出沿连续方向访问(缓存友好),输出先清零再累积。反向传播按梯度公式实现:`grad_input = grad_output × otherᵀ`,`grad_other = inputᵀ × grad_output`(逐 batch 独立)。 +**CUDA 实现**:采用"一元素一线程 + 内层串行累加 + 边界检查"的 naive GEMM 形态(与 NVIDIA 官方 naive 变体一致)。每个输出元素由固定线程按固定累加顺序(k 升序)串行计算,保证数值确定性——测试使用 `EXPECT_FLOAT_EQ` 精确比较,固定累加顺序是精确匹配的前提。网格按 CEIL_DIV 划分(256 线程块),kernel 内 `idx >= total` 越界防护,与官方 kernel 风格一致。另补了 32 位索引范围防护(total 超出 int 范围时快速失败)。 #### 遇到问题 - +1. **batch 语义与 torch.matmul 的差异**:实现为"逐 batch 严格相等"(不支持广播),与 torch.matmul 的广播语义不同——测试仅覆盖 batch 相等场景,实现语义已在注释中说明(教学框架内与官方 `LinearForward` 的前缀展平语义一致)。 +2. **CPU/CUDA 数值的 ULP 级差异**:nvcc 默认开启 FMA 融合(`--fmad=true`),CUDA 版每个输出元素累加时乘加被融合为 FFMA,与 CPU 版分离乘加存在 1 ULP 级差异——测试数据为小整数(乘积精确)不受影响,注释中已如实说明。 ### 作业三:实现Adam优化器 @@ -106,7 +304,7 @@ std::vector> Neg::Backward(const std::vector &grad, const std::shared_ptr ¶m, @@ -116,6 +314,27 @@ void AdamAccumulateGrad(const std::shared_ptr &grad, const std::shared_p // TODO:实现Adam优化器的梯度累积和参数更新 // REF: // =================================== 作业 =================================== + // 标准 Adam(含偏差校正):m = β1*m + (1-β1)*g,v = β2*v + (1-β2)*g², + // param -= lr * m̂ / (√v̂ + eps),其中 m̂ = m/(1-β1^t),v̂ = v/(1-β2^t),t 从 1 开始 + CHECK_EQ(grad->NumElements(), param->NumElements()); // 校验各张量元素数一致,防止直接调用时越界读写 + CHECK_EQ(m->NumElements(), param->NumElements()); + CHECK_EQ(v->NumElements(), param->NumElements()); + CHECK_GE(t, 1); // Adam 偏差校正要求 t 从 1 开始(t=0 时 1-β^t=0,除零产生 NaN) + // 偏差校正因子仅依赖步数,与元素无关,提升到循环外计算一次(double 对齐 PyTorch 标量路径的双精度惯例) + const double beta1_t = std::pow(static_cast(beta1), static_cast(t)); + const double beta2_t = std::pow(static_cast(beta2), static_cast(t)); + for (int64_t idx = 0; idx < param->NumElements(); ++idx) { + const float g = static_cast(grad->DataPtr())[idx]; + float ¶m_elem = static_cast(param->DataPtr())[idx]; + float &m_elem = static_cast(m->DataPtr())[idx]; + float &v_elem = static_cast(v->DataPtr())[idx]; + + m_elem = beta1 * m_elem + (1.0f - beta1) * g; + v_elem = beta2 * v_elem + (1.0f - beta2) * g * g; + const float m_hat = static_cast(m_elem / (1.0 - beta1_t)); + const float v_hat = static_cast(v_elem / (1.0 - beta2_t)); + param_elem -= learning_rate * m_hat / (std::sqrt(v_hat) + eps); + } } ``` @@ -123,7 +342,7 @@ void AdamAccumulateGrad(const std::shared_ptr &grad, const std::shared_p 对应测例:`TEST(AdamOptimizerTest, BasicParameterUpdateCuda)`,`TEST(AdamOptimizerTest, MomentumAccumulationCuda)` -代码位置:infini_train/src/kernels/cuda/accumulate_grad.cu +代码位置:`infini_train/src/kernels/cuda/accumulate_grad.cu` ```c++ void AdamAccumulateGrad(const std::shared_ptr &grad, const std::shared_ptr ¶m, @@ -133,16 +352,44 @@ void AdamAccumulateGrad(const std::shared_ptr &grad, const std::shared_p // TODO:实现Adam优化器的梯度累积和参数更新 // REF: // =================================== 作业 =================================== + CHECK_EQ(grad->NumElements(), param->NumElements()); + CHECK_EQ(m->NumElements(), param->NumElements()); + CHECK_EQ(v->NumElements(), param->NumElements()); + CHECK_GE(t, 1); // Adam 偏差校正要求 t 从 1 开始(t=0 时 1-β^t=0,除零产生 NaN) + // 偏差校正因子仅依赖步数,与元素无关,host 侧计算一次(double,与 CPU 版一致) + const double beta1_t = std::pow(static_cast(beta1), static_cast(t)); + const double beta2_t = std::pow(static_cast(beta2), static_cast(t)); + + size_t num_elements = param->NumElements(); + // 32 位索引范围防护(超过 int 范围时快速失败,避免后半元素静默不处理) + CHECK_LE(num_elements, static_cast(std::numeric_limits::max())); + // kernel 的 restrict 承诺要求写目标(param/m/v)与读源(grad)互不重叠(原地调用属未定义行为) + const float *grad_ptr = static_cast(grad->DataPtr()); + float *param_ptr = static_cast(param->DataPtr()); + float *m_ptr = static_cast(m->DataPtr()); + float *v_ptr = static_cast(v->DataPtr()); + + int threads_per_block = 256; + int num_blocks = (num_elements + threads_per_block - 1) / threads_per_block; + AdamAccumulateGradKernel<<>>(grad_ptr, param_ptr, m_ptr, v_ptr, learning_rate, + beta1, beta2, eps, beta1_t, beta2_t, num_elements); } ``` #### 解决思路 +标准 Adam 公式直译(CPU/CUDA 语义一致): +- 一阶矩:`m = β₁·m + (1-β₁)·g` +- 二阶矩:`v = β₂·v + (1-β₂)·g²` +- 参数更新:`θ -= lr · m̂/(√v̂ + eps)`,其中偏差校正 `m̂ = m/(1-β₁ᵗ)`、`v̂ = v/(1-β₂ᵗ)`,`t` 从 1 开始(由官方 `Adam::Step` 先 `++t_` 保证) -#### 遇到问题 +**关键设计**:偏差校正因子(`β^t`)与元素无关、仅依赖步数,故提升到**循环外计算一次**(用 double 计算,对齐 PyTorch 标量路径的双精度惯例),消除每元素重复的 2N 次幂运算;`m`/`v` 为跨步状态(官方 `Adam` 类管理),kernel 内只做逐元素原位更新。**CUDA 实现**:一元素一线程 + 边界检查,host 侧计算 double 偏差因子传入 kernel,kernel 内 `sqrtf` 计算分母。 +#### 遇到问题 +1. **`MomentumAccumulation` 用例的精确对照**:测试用 1e-5 容差逐值对照标准公式(含偏差校正)——偏差校正缺失或公式有误必然失败,实现必须与 PyTorch 公式严格一致。 +2. **数值精度**:偏差校正因子的幂运算用 double(而非 float)计算,避免 `int64_t` 步数转 float 的精度损失(超过 2²⁴ 步时可见),与 PyTorch 标量路径一致。 ### 作业四:实现Tensor基础操作 @@ -160,6 +407,32 @@ std::shared_ptr Tensor::Flatten(int64_t start, int64_t end) { // TODO:实现张量扁平化操作,将指定维度范围[start, end]内的所有维度合并为一个维度 // HINT: // =================================== 作业 =================================== + if (dims_.empty()) { + // 零维张量(标量)无维度可合并,返回原张量(教学框架简化语义;PyTorch 现行为对 0 维 flatten 返回一维视图) + return shared_from_this(); + } + if (start < 0) { + start += dims_.size(); + } + if (end < 0) { + end += dims_.size(); + } + CHECK_GE(start, 0); + CHECK_LT(start, dims_.size()); + CHECK_GE(end, start); + CHECK_LT(end, dims_.size()); + + int64_t flattened_dim = 1; + for (int64_t dim = start; dim <= end; ++dim) { + flattened_dim *= dims_[dim]; + } + + std::vector new_shape; + new_shape.insert(new_shape.end(), dims_.begin(), dims_.begin() + start); + new_shape.push_back(flattened_dim); + new_shape.insert(new_shape.end(), dims_.begin() + end + 1, dims_.end()); + + return Contiguous()->View(new_shape); } ``` @@ -176,18 +449,38 @@ void Tensor::Backward(std::shared_ptr gradient, bool retain_graph, bool // =================================== 作业 =================================== // TODO:实现自动微分反向传播 // 功能描述:1. 计算当前张量对叶子节点的梯度 2. 支持多输出场景的梯度累加 - // HINT: // =================================== 作业 =================================== + if (!gradient) { + // 无参调用(对应 PyTorch 标量输出 tensor.backward() 语义):默认梯度为全 1 + // 非标量输出同样构造全 1 梯度(数学上等价于显式 ones 梯度),与 PyTorch 的标量检查不同, + // 属教学框架的宽容语义;默认梯度经 Fill kernel 按 float 填充,当前仅支持框架使用的 float32 路径。 + gradient = std::make_shared(dims_, dtype_, GetDevice()); + gradient->Fill(1.0f); + } else { + // 透传前校验梯度形状与设备一致,防止元素错位静默累加与越界写 + CHECK(gradient->Dims() == dims_) << "Backward gradient shape mismatch"; + CHECK_EQ(static_cast(gradient->GetDevice().Type()), static_cast(GetDevice().Type())) + << "Backward gradient device mismatch"; + } + if (grad_fn_) { + grad_fn_->BackwardPartial(gradient, output_idx_); + } + // 契约说明(与 PyTorch 语义的差异):同一计算图仅可传播一次,重复传播为未定义行为; + // retain_graph/create_graph 参数当前无效果;无梯度函数的张量(叶子)调用为空操作。 } ``` #### 解决思路 +**Flatten**:采用官方注释中预留的实现提示 `Contiguous()->View(new_shape)`——先做负索引规范化(`start`/`end` 为负时加 `dims_.size()`,与同文件 `Squeeze` 风格一致),边界校验(越界、`start > end` 快速失败),计算合并后维度,构造 new_shape 后复用已实现的 `Contiguous()`/`View()`。非连续张量场景(如 Transpose 结果)由 `Contiguous()` 先物理重排再改形状元数据,数据顺序正确。 +**Backward**:接入官方已实现的 autograd 引擎(`Function::BackwardPartial` 递归传播 + `AccumulateGrad` 叶子梯度累积),不自写图遍历:`grad_fn_->BackwardPartial(gradient, output_idx_)`。无参调用(`gradient == nullptr`)时构造与输出同形状/同设备/同 dtype 的全 1 默认梯度(PyTorch 标量输出 backward 语义);显式传入梯度时先校验形状与设备一致(防止元素错位静默累加与越界写);`grad_fn_` 为空(叶子/未参与求导)时安全忽略。多输出梯度累加由引擎的 `AccumulateGrad` 机制保证(两次独立 Backward 自然累加到同一叶子)。 #### 遇到问题 - +1. **显式梯度无校验的越界风险**:Backward 透传梯度前若不校验形状,元素数不匹配时会越界写叶子梯度缓冲(静默内存破坏)——补了形状 + 设备一致性 CHECK(快速失败,对齐项目错误处理风格)。 +2. **零维张量 Flatten 崩溃**:零维张量(框架内合法,标量参与求导)调用 Flatten 时维数检查必然失败(CHECK 死亡)——补齐零维短路返回原张量(教学框架简化语义)。 +3. **重复传播的限制**:同一计算图仅可传播一次(官方引擎传播后不重置依赖计数,重复传播为未定义行为)——根因在官方引擎,作业侧以契约注释文档化(`retain_graph`/`create_graph` 参数当前无效果)。 ### 作业五 注册算子kernel的实现 @@ -202,8 +495,10 @@ template RetT Call(ArgsT... args) const { // =================================== 作业 =================================== // TODO:实现通用kernel调用接口 // 功能描述:将存储的函数指针转换为指定类型并调用 - // HINT: // =================================== 作业 =================================== + + using FuncT = RetT (*)(ArgsT...); + return reinterpret_cast(func_ptr_)(args...); } template void Register(const KeyT &key, FuncT &&kernel) { @@ -211,22 +506,34 @@ template void Register(const KeyT &key, FuncT &&kernel) { // TODO:实现kernel注册机制 // 功能描述:将kernel函数与设备类型、名称绑定 // =================================== 作业 =================================== + CHECK(!key_to_kernel_map_.contains(key)) + << "Kernel already registered: " << key.second << " on device: " << static_cast(key.first); + key_to_kernel_map_.emplace(key, KernelFunction(std::forward(kernel))); } -#define REGISTER_KERNEL(device, kernel_name, kernel_func) \ - // =================================== 作业 =================================== - // TODO:实现自动注册宏 - // 功能描述:在全局静态区注册kernel,避免显式初始化代码 - // =================================== 作业 =================================== +#define INFINI_KERNEL_CONCAT_(a, b) a##b +#define INFINI_KERNEL_CONCAT(a, b) INFINI_KERNEL_CONCAT_(a, b) +#define REGISTER_KERNEL(device, kernel_name, kernel_func) \ + /* =================================== 作业 =================================== */ \ + /* TODO:实现自动注册宏 */ \ + /* 功能描述:在全局静态区注册kernel,避免显式初始化代码 */ \ + /* =================================== 作业 =================================== */ \ + static const bool INFINI_KERNEL_CONCAT(kernel_reg_, __COUNTER__) = \ + (infini_train::Dispatcher::Instance().Register( \ + std::make_pair(device, #kernel_name), kernel_func), \ + true); ``` #### 解决思路 - +1. **`KernelFunction::Call`**:类型擦除的通用调用接口——将存储的 `void*` 函数指针还原为 `RetT(*)(ArgsT...)` 并调用。`return void_expr;` 在 `RetT=void` 时合法(C++ [stmt.return]/2),故 void / int / `shared_ptr` / `tuple<...>` 返回均通用(框架中 30+ 个 kernel 签名各异均能调用)。 +2. **`Dispatcher::Register`**:往 `key_to_kernel_map_`(键 = `(设备类型, kernel名)`)注册 kernel,重复注册用 glog `CHECK` 快速失败(死亡消息含设备与名称,`EXPECT_DEATH` 可匹配)。 +3. **`REGISTER_KERNEL` 宏**:实现"文件加载即注册"的静态注册机制——`__COUNTER__` + 两层 concat(PyTorch `ANONYMOUS_VARIABLE` 同款模式)保证每个注册点变量名唯一(多编译单元、同 TU 多次调用均不冲突),comma-expression 在静态变量动态初始化时触发注册。`#kernel_name` 字符串化作键名。与各 kernel 文件底部的包装宏(`REGISTER_CPU_LINEAR_KERNEL` 等)兼容。 #### 遇到问题 - +1. **重复注册的死亡语义**:`DuplicateRegistration` 用例要求重复注册同一 key 时进程死亡且消息含 `Kernel already registered`——用 `CHECK` 实现,并确认 gtest 的 `EXPECT_DEATH`(ContainsRegex 部分匹配)在两种死亡测试风格(fast/threadsafe)下均能捕获。 +2. **签名拼写契约**:调用方以"按值"拼写 `shared_ptr` 参数,而 kernel 形参为"常引用"——两者在 Itanium ABI 下等价(非平凡拷贝构造类按隐藏引用传递),属类型擦除分发的设计固有约定,已固化为项目契约文档。 ### 作业六:实现GPT-2整体训练 @@ -238,6 +545,8 @@ template void Register(const KeyT &key, FuncT &&kernel) { 完成以上所有作业,补齐训练框架的所有实现,理论上`TEST_F(GPT2TrainingTest, LogitsConsistency)`可以通过,在用例中判断比较预置的值和单步正向传播计算结果是否在误差允许范围内相等。 +> 说明:该用例实际为 GPT-2 124M 训练 11 步(每步含前向、反向与 SGD 参数更新)后,对最终 logits 与参考文件抽样 100 点、按 1e-3 绝对容差对比。 + #### 数据读取实现 代码位置:example/common/tiny_shakespeare_dataset.cc @@ -252,9 +561,44 @@ TinyShakespeareFile ReadTinyShakespeareFile(const std::string &path, size_t sequ | magic(4B) | version(4B) | num_toks(4B) | reserved(1012B) | token数据 | ---------------------------------------------------------------------------------- =================================== 作业 =================================== */ + std::ifstream ifs(path, std::ios::binary); + CHECK(ifs.is_open()) << "Failed to open file: " << path; + + auto header = ReadSeveralBytesFromIfstream(1024, &ifs); + CHECK_EQ(ifs.gcount(), 1024) << "Truncated header in file: " << path; + const int magic = BytesToType(header, 0); + const int num_toks = BytesToType(header, 8); + CHECK(kTypeMap.contains(magic)) << "Unsupported magic number: " << magic; + CHECK_GE(num_toks, 0) << "Invalid num_toks in file: " << path; + + TinyShakespeareFile file; + file.type = kTypeMap.at(magic); + const size_t token_size = kTypeToSize.at(file.type); + + // 读 token 流并转为 int64 张量:CrossEntropy 的 target 要求 int64, + // 且 operator[] 中 y 相对 x 偏移 sizeof(int64_t) = 8 字节,恰为一个 int64 token(预测下一 token) + const size_t num_tok_bytes = static_cast(num_toks) * token_size; + auto token_bytes = ReadSeveralBytesFromIfstream(num_tok_bytes, &ifs); + // 读取完整性校验(损坏/截断文件下避免短向量导致的越界读) + CHECK_EQ(static_cast(ifs.gcount()), num_tok_bytes) << "Truncated token data in file: " << path; + const size_t num_samples = static_cast(num_toks) / sequence_length; + file.dims = {static_cast(num_samples), static_cast(sequence_length)}; + file.tensor = infini_train::Tensor(file.dims, DataType::kINT64); + int64_t *tensor_data = static_cast(file.tensor.DataPtr()); + for (size_t i = 0; i < num_samples * sequence_length; ++i) { + tensor_data[i] = (token_size == 2) ? BytesToType(token_bytes, i * 2) + : BytesToType(token_bytes, i * 4); + } + return file; } -TinyShakespeareDataset::TinyShakespeareDataset(const std::string &filepath, size_t sequence_length) { +TinyShakespeareDataset::TinyShakespeareDataset(const std::string &filepath, size_t sequence_length) + : text_file_(ReadTinyShakespeareFile(filepath, sequence_length)), + sequence_length_(sequence_length), + sequence_size_in_bytes_(sequence_length * sizeof(int64_t)), + // 最后一个序列缺"下一 token"作标签,operator[] 以 CHECK_LT(idx, dims[0]-1) 限定可访问边界, + // 故 Size 报告可访问样本数 = dims[0]-1(空数据集防护为 0),与 operator[] 的契约一致 + num_samples_(text_file_.dims[0] > 0 ? text_file_.dims[0] - 1 : 0) { // =================================== 作业 =================================== // TODO:初始化数据集实例 // HINT: 调用ReadTinyShakespeareFile加载数据文件 @@ -277,6 +621,25 @@ Tokenizer::Tokenizer(const std::string &filepath) { | magic(4B) | version(4B) | vocab_size(4B) | reserved(1012B) | token词表数据 | ---------------------------------------------------------------------------------- ===================================== 作业 ===================================== */ + std::ifstream ifs(filepath, std::ios::binary); + CHECK(ifs.is_open()) << "Failed to open tokenizer file: " << filepath; + + auto header = ReadSeveralBytesFromIfstream(1024, &ifs); + CHECK_EQ(ifs.gcount(), 1024) << "Truncated header in file: " << filepath; + magic_number_ = BytesToType(header, 0); + vocab_size_ = BytesToType(header, 8); + eot_token_ = BytesToType(header, 12); + CHECK(kEotMap.contains(magic_number_)) << "Unsupported tokenizer magic number: " << magic_number_; + + // 词表格式(与 llm.c gpt2_tokenizer.bin 一致):每个 token 为 1 字节长度前缀 + 原始字节 + token_table_.reserve(vocab_size_); + for (uint32_t i = 0; i < vocab_size_; ++i) { + const uint8_t len = BytesToType(ReadSeveralBytesFromIfstream(1, &ifs), 0); + auto bytes = ReadSeveralBytesFromIfstream(len, &ifs); + token_table_.emplace_back(reinterpret_cast(bytes.data()), len); + } + // 词表读取完整性校验:任一项读取失败(文件截断)都会置位 failbit,快速失败而非静默加载垃圾词表 + CHECK(ifs) << "Truncated tokenizer vocab table in file: " << filepath; } ``` @@ -286,19 +649,68 @@ std::string Tokenizer::Decode(uint32_t token_id) const { TODO:实现token_id到文本的转换 功能描述:根据token_id返回对应的文本片段 ===================================== 作业 ===================================== */ + CHECK_LT(token_id, vocab_size_) << "token_id out of range: " << token_id; + return token_table_[token_id]; } ``` ```c++ void Tokenizer::GenerateText(infini_train::nn::Module &model, uint32_t batch_size, uint32_t sequence_length, uint32_t text_length, Device device) const { - /* ...原代码... */ + /* 函数前部官方代码(x_tensor 初始化与 prompt 填充)省略,与源码一致 */ LOG(INFO) << "start generate text:"; for (int t = prompt_len; t < text_length; t++) { /* ===================================== 作业 ===================================== TODO:实现单步文本生成逻辑 HINT:调用model.Forward推理获取logits,根据推理结果进行随机采样,调用Decode获取文本结果 ===================================== 作业 ===================================== */ + // 生成场景无 Backward:临时禁用参数梯度使前向不建 autograd 图(Function 即时释放), + // 避免算子 saved_tensors_ 的循环引用在无 Backward 场景下导致显存逐步泄漏。 + // 副作用说明:本方法(const 签名)临时修改模型参数的 requires_grad 状态——生成区间禁用、 + // 末步恢复为 true;若生成中途以异常中断,requires_grad 将残留为 false,需重新调用 + // set_requires_grad(true) 恢复训练(本框架错误处理为 CHECK 快速失败,正常路径不触发) + if (t == prompt_len) { + for (auto ¶m : model.Parameters()) { param->set_requires_grad(false); } + } + // 同步 host 输入到目标设备并前向推理 + x = std::make_shared(x_tensor.To(device)); + auto outputs = model.Forward({x}); + auto logits = outputs[0]; + auto logits_cpu = logits->To(Device(DeviceType::kCPU, 0)); + const float *logits_data = static_cast(logits_cpu.DataPtr()); + const int64_t vocab_size = logits->Dims()[2]; + // 恢复参数梯度(后续训练仍需要建图反向) + if (t == text_length - 1) { + for (auto ¶m : model.Parameters()) { param->set_requires_grad(true); } + } + + // 生成语义(对齐 llm.c):取位置 t-1 的分布预测位置 t 的 token,每 batch 独立采样写回 + std::vector probs(static_cast(vocab_size)); + for (uint32_t b = 0; b < batch_size; ++b) { + const float *logits_at_t = logits_data + (b * sequence_length + t - 1) * vocab_size; + // softmax(数值稳定:先减最大值) + float max_logit = logits_at_t[0]; + for (int64_t i = 1; i < vocab_size; ++i) { + if (logits_at_t[i] > max_logit) { + max_logit = logits_at_t[i]; + } + } + float sum_exp = 0.0f; + for (int64_t i = 0; i < vocab_size; ++i) { + probs[i] = std::exp(logits_at_t[i] - max_logit); + sum_exp += probs[i]; + } + for (int64_t i = 0; i < vocab_size; ++i) { + probs[i] /= sum_exp; + } + + // 函数开头声明了与命名空间常量同名的局部变量 `kRngState = kRngState;`(自初始化,未定义行为), + // 此处显式引用命名空间常量 kRngState(=1337,与 llm.c 固定种子一致)保证生成可复现 + static uint64_t rng_state = infini_train::kRngState; + const int next_token = SampleMult(probs.data(), static_cast(vocab_size), RandomF32(rng_state)); + x_buff[b * sequence_length + t] = next_token; + std::cout << Decode(next_token); + } } std::cout << std::endl; } @@ -306,7 +718,18 @@ void Tokenizer::GenerateText(infini_train::nn::Module &model, uint32_t batch_siz #### 解决思路 - +1. **数据读取**(`ReadTinyShakespeareFile`):解析 1024B HEADER(magic/version/num_toks + reserved),magic 经 `kTypeMap` 映射到 token 类型(GPT-2 为 uint16),token 流读取后转为 int64 张量(`dims = {num_samples, sequence_length}`,`num_samples = num_toks / sequence_length`)。`TinyShakespeareDataset` 构造函数初始化 `text_file_`/`sequence_length_`/`sequence_size_in_bytes_`/`num_samples_`——其中 `num_samples_` 与官方 `operator[]` 的边界契约对齐(可访问样本数 = `dims[0]-1`,最后一个序列缺少"下一 token"作标签)。 +2. **Tokenizer**:加载与 llm.c 格式一致的词表文件(HEADER 中 eot_token 位于偏移 12;词表为"1 字节长度前缀 + 原始字节"变长记录),装入 `token_table_`;`Decode` 查表返回(越界 token_id 快速失败);`GenerateText` 实现逐 token 生成:取位置 t-1 的 logits 分布 → softmax(先减最大值保证数值稳定)→ `SampleMult` 多项式采样 → 写回输入张量 → `Decode` 打印,生成语义与 llm.c 一致(eot 填充 + prompt 引导 + 固定步数)。 +3. **生成场景显存处理**:生成无反向传播,autograd 图的 `saved_tensors_` 循环引用在无 Backward 场景下无法释放(每步 forward 显存占用逐步增长,多轮生成后可耗尽显存)——在生成循环首步临时禁用参数 `requires_grad`(前向不建图、Function 即时释放)、末步恢复,消除泄漏且不影响后续训练。 #### 遇到问题 +**test_gpt2 的 logits 对比存在环境敏感性(重点排查过程)**: + +初版实现后 `LogitsConsistency` 失败(logits 与参考文件差异 0.37~1.54),经系统排查(数据/模型/参考文件内容级验证、单算子数值对照、训练轨迹逐步转储、GPU 运行间确定性实验、compute-sanitizer 内存检查、5090 服务器交叉验证),确认: + +1. **数据/模型/参考文件全部为官方原版**:模型/数据/tokenizer 与官方 md5 逐字节一致,模型逐张量与 HuggingFace gpt2 权重一致(转置后 max_diff=0),参考文件与 git 历史入库版本逐字节一致;单步前向 loss(5.35609)与 llm.c 官方微调示例(5.356189)一致——实现链路正确。 +2. **实现与参考实现数值行为一致**:作业实现的 Matmul kernel 与参考二进制的 SASS/PTX 逐指令一致(diff=0)。 +3. **失败根因 = 环境敏感性**:参考文件由参考实现在本地 CUDA 13.3 环境运行产生(本地复现中,11 步训练后的 loss 与参考精确一致);而 fp32 训练的数值轨迹对运行环境敏感——**cuBLAS/nvcc 版本差异(12.8 vs 13.3)为主因**(12.8 工具链编译稳定失败、13.3 环境通过),GPU 架构与原子累加噪声为次要因素(同为 13.3 时不同 GPU 的噪声分布不同):每步 ULP 级数值差被训练轨迹中换行符位置约 1e5 倍的高灵敏度方向混沌放大,使 11 步训练后的 logits 与参考产生 0.002~7.6 的差异(同一二进制多次运行结果不同,GPU 原子累加顺序随机所致)。测试容差 1e-3 低于该噪声地板。 +4. **结论**:在 5090 + CUDA 13.3 运行时环境下全量 8/8 通过(test_gpt2 通过);在 4060(Ada 架构)或 CUDA 12.8 环境下 test_gpt2 因上述环境性差异失败(其余 7/8 全过)。该问题应该与作业实现质量无关,是测试对运行环境的敏感依赖,已在截图下方说明测试环境。 + diff --git "a/docs/test\351\200\232\350\277\207\346\210\252\345\233\276.png" "b/docs/test\351\200\232\350\277\207\346\210\252\345\233\276.png" new file mode 100644 index 0000000000000000000000000000000000000000..7beaa9db031b8e0b7dfd049c428ec4a4cb683041 GIT binary patch literal 121420 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 yO+GbU&FG#tsu8^as;b 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 literal 0 HcmV?d00001