diff --git a/README.md b/README.md index ed3f549bd..c7001f207 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ --- -![English Coverage](https://img.shields.io/badge/en_coverage-92%25-green.svg) 534/582 docs translated +![English Coverage](https://img.shields.io/badge/en_coverage-92%25-green.svg) 544/592 docs translated ## 这是什么项目 diff --git a/code/volumn_codes/vol8-labs/ai/tiny_ml/stage0/.gitignore b/code/volumn_codes/vol8-labs/ai/tiny_ml/stage0/.gitignore new file mode 100644 index 000000000..a5309e6b9 --- /dev/null +++ b/code/volumn_codes/vol8-labs/ai/tiny_ml/stage0/.gitignore @@ -0,0 +1 @@ +build*/ diff --git a/code/volumn_codes/vol8-labs/ai/tiny_ml/stage0/CMakeLists.txt b/code/volumn_codes/vol8-labs/ai/tiny_ml/stage0/CMakeLists.txt new file mode 100644 index 000000000..2ab134da3 --- /dev/null +++ b/code/volumn_codes/vol8-labs/ai/tiny_ml/stage0/CMakeLists.txt @@ -0,0 +1,45 @@ +cmake_minimum_required(VERSION 3.20) + +project(tamcpp_mlinfra LANGUAGES CXX) + +set(CMAKE_CXX_STANDARD 23) +set(CMAKE_CXX_STANDARD_REQUIRED ON) + +set(CMAKE_EXPORT_COMPILE_COMMANDS ON) + +# 不调这个,CMake 不生成 CTestTestfile.cmake,catch_discover_tests 注册的 +# add_test 全悬空,ctest 永远 No tests found。 +enable_testing() + +message(STATUS "Ready to fetch the necessary test framework") + +include(FetchContent) + +FetchContent_Declare( + Catch2 + GIT_REPOSITORY https://github.com/catchorg/Catch2.git + GIT_TAG v3.5.0 +) + +FetchContent_MakeAvailable(Catch2) +list(APPEND CMAKE_MODULE_PATH ${catch2_SOURCE_DIR}/extras) + +# 顶层只把 Catch2 的 extras 加进了 MODULE_PATH,要用 catch_discover_tests +# 还得自己 include(Catch) 把命令加载进来。 +include(Catch) + +# 跨编译器的 warning 选项封进一个函数,后续每个 target 一行调用就行, +# 不用在 CMakeLists.txt 里反复抄 generator-expression。 +# 比较坑的点:这个 Lab 要同时吃 MSVC 和 GCC/Clang,两边选项根本不通用, +# 只能用 genex 按编译器分流——还得是跑到 Windows 下编译一轮才吃到这个亏! +function(tamcpp_target_warnings target) + target_compile_options(${target} PRIVATE + $<$:/W4;/permissive-;/Zi> + $<$>:-Wall;-Wextra;-Wpedantic;-g> + ) +endfunction() + +add_executable(smoke_catch2 tests/smoke.cpp) +target_link_libraries(smoke_catch2 PRIVATE Catch2::Catch2WithMain) +tamcpp_target_warnings(smoke_catch2) +catch_discover_tests(smoke_catch2) diff --git a/code/volumn_codes/vol8-labs/ai/tiny_ml/stage0/logs/001-mkdir-p-brace-expansion.md b/code/volumn_codes/vol8-labs/ai/tiny_ml/stage0/logs/001-mkdir-p-brace-expansion.md new file mode 100644 index 000000000..53cda1e03 --- /dev/null +++ b/code/volumn_codes/vol8-labs/ai/tiny_ml/stage0/logs/001-mkdir-p-brace-expansion.md @@ -0,0 +1,40 @@ +# 001 · `mkdir -p dir/{a,b,c}` 拆解 + +> 学习日志。每条记录一个已达成共识的知识点。 +> 出处:`tinyinfercpp-handbook.md` M0 第一步(第 244 行)。 + +## 原命令 + +```bash +mkdir -p tinyinfercpp-lab/{include/tinyinfer,src,tests,tools,examples/hello_inference} +``` + +## 结论:三个独立机制拼接 + +| 部分 | 机制 | 作用 | +|------|------|------| +| `mkdir` | make directory | 建目录 | +| `-p` | `--parents` | ① 递归建出缺失的父目录;② 幂等(目录已存在不报错,退出码 0) | +| `{a,b,c}` | bash **花括号展开(brace expansion)** | 纯 shell 文本替换,把逗号分隔项各自拼上前缀,展开成多个独立参数 | + +## 实证(2026-06-30 跑通) + +- **花括号展开**:`echo tinyinfercpp-lab/{include/tinyinfer,src,...}` 输出 5 个独立路径,证明是先展开成多参数再交给命令。 +- **不带 `-p`**:父目录缺失时 `mkdir: cannot create directory ...: No such file or directory`,退出码 1。 +- **带 `-p`**:`a/b/c` 一路补齐建成;重复执行不报错(退出码 0);不带 `-p` 重复执行报 `File exists`,退出码 1。 + +## 等价形式 + +```bash +mkdir -p tinyinfercpp-lab/include/tinyinfer +mkdir -p tinyinfercpp-lab/src +mkdir -p tinyinfercpp-lab/tests +mkdir -p tinyinfercpp-lab/tools +mkdir -p tinyinfercpp-lab/examples/hello_inference +``` + +## 坑 + +- 花括号展开是 **bash/zsh** 特性。`#!/bin/sh`(dash 等 POSIX shell)**不保证支持**,会把 `{...}` 当字面目录名建出来。本机 zsh/bash 无此问题。 +- 花括号里**逗号前后不能有空格**,否则不触发展开(变成字面量)。 +- 本命令必须 `-p`:要建的 `include/tinyinfer`、`examples/hello_inference` 是两层深,且父目录 `tinyinfercpp-lab/` 也还不存在。 diff --git a/code/volumn_codes/vol8-labs/ai/tiny_ml/stage0/logs/002-fetchcontent-catch2.md b/code/volumn_codes/vol8-labs/ai/tiny_ml/stage0/logs/002-fetchcontent-catch2.md new file mode 100644 index 000000000..6385af95e --- /dev/null +++ b/code/volumn_codes/vol8-labs/ai/tiny_ml/stage0/logs/002-fetchcontent-catch2.md @@ -0,0 +1,48 @@ +# 002 · FetchContent 拉取 Catch2 机制 + +> 出处:`CMakeLists.txt` 第 14–22 行。 +> 关联:handbook M0 第 259–266 行(骨架)、坑 1(第 332–336 行)。 + +## 原命令 + +```cmake +include(FetchContent) +FetchContent_Declare(Catch2 + GIT_REPOSITORY https://github.com/catchorg/Catch2.git + GIT_TAG v3.5.0) +FetchContent_MakeAvailable(Catch2) +list(APPEND CMAKE_MODULE_PATH ${catch2_SOURCE_DIR}/extras) +``` + +## 一句话定位 + +在 CMake **配置阶段(configure time)** 从 Git 拉 Catch2 源码,直接 `add_subdirectory` 同构编译进工程,无需预装/无 submodule/无 vcpkg。版本由 `GIT_TAG v3.5.0` 锁死。 + +## 三步机制 + +| 步骤 | 行为 | 何时下载 | +|------|------|----------| +| `FetchContent_Declare` | 只登记(name + 来源 + tag),写入 CMake 内部记录 | **不下载** | +| `FetchContent_MakeAvailable` | 首次配置时 clone + `add_subdirectory` + 定义 target(`Catch2::Catch2WithMain`) + 设 `_SOURCE_DIR` 变量 | **下载** | +| `list(APPEND CMAKE_MODULE_PATH .../extras)` | 把 Catch2 的辅助 `.cmake`(`Catch.cmake` 等,含 `catch_discover_tests`)挂进搜索路径,供后续 `include(Catch)` | — | + +变量名规则:`_SOURCE_DIR` 用**全小写** depname → 这里是 `catch2_SOURCE_DIR`。 + +下载落点:`build/_deps/-src/`(源码)、`-build/`(中间产物)。二次配置不重拉。不污染源码树。 + +## 实证(2026-06-30,/tmp 真实拉取 —— 失败,但日志坐实机制) + +- `[1] Declare 之前` 与 `[2] Declare 之后` 两条 message 都打印 → 证明 **Declare 阶段磁盘无任何下载**。 +- `[22%] Performing download step (git clone) for 'catch2-populate'` + `Cloning into 'catch2-src'` → 下载由 FetchContent 内部生成的 `catch2-populate` 子项目驱动 `git clone`,目标即 `catch2-src/`。 +- 失败后 `_deps/` 有 `catch2-build`/`catch2-subbuild` 但**无 `catch2-src`** → 反向证明 `catch2-src/` 才是源码位置。 + +## 坑 1(实证命中):网络拉不下来 + +失败日志:`Failed to connect to github.com port 443 after 133656 ms`(重试 3 次)、`SSL_read: unexpected eof`。WSL 访问 github 不稳定。 + +**救急**(handbook 给的):手动浅克隆后用预放置目录替换自动下载: +```bash +git clone --depth 1 -b v3.5.0 https://github.com/catchorg/Catch2.git /tmp/catch2 +cmake -S . -B build -DFETCHCONTENT_SOURCE_DIR_CATCH2=/tmp/catch2 +``` +变量名规则:`FETCHCONTENT_SOURCE_DIR_<大写DEPNAME>`,本质是替换 `GIT_REPOSITORY` 的下载源。 diff --git a/code/volumn_codes/vol8-labs/ai/tiny_ml/stage0/logs/003-target-compile-options.md b/code/volumn_codes/vol8-labs/ai/tiny_ml/stage0/logs/003-target-compile-options.md new file mode 100644 index 000000000..880290c02 --- /dev/null +++ b/code/volumn_codes/vol8-labs/ai/tiny_ml/stage0/logs/003-target-compile-options.md @@ -0,0 +1,64 @@ +# 003 · `target_compile_options` 与警告三件套 + `-g` + +> 出处:`CMakeLists.txt` 第 27 行。 +> 本机:g++ 16.1.1 / clang++ 22.1.6。 + +## 原命令 + +```cmake +target_compile_options(smoke_catch2 PRIVATE -Wall -Wextra -Wpedantic -g) +``` + +## ① 命令骨架 + +`target_compile_options( )` —— **现代 CMake(target-based)** 写法,把选项挂在指定 target 上,对应老式全局 `add_compile_options()`。哲学:属性挂 target,谁需要谁加,不污染全局。 + +- `smoke_catch2` —— 接收方,第 25 行 `add_executable(smoke_catch2 tests/smoke.cpp)` 建的测试可执行文件。 +- `PRIVATE` —— 传递性关键字: + + | 关键字 | 编译自己 | 传给下游 | + |--------|:-------:|:--------:| + | PRIVATE | ✅ | ❌ | + | INTERFACE | ❌ | ✅ | + | PUBLIC | ✅ | ✅ | + + **警告标志几乎总是 PRIVATE**:① 下游不关心你怎么编译它;② 警告标志编译器相关(`-Wall` 是 GCC/Clang,MSVC 是 `/W4` 且 `/Wall` 含义不同会刷屏),PUBLIC 传下去换编译器就炸。 + +## ② 四个标志(2026-06-30 实证) + +`-Wall -Wextra -Wpedantic` = 警告三件套,逐级加严。演示埋三雷各吃一个: + +| 标志 | 命中警告 | 含义 | +|------|----------|------| +| `-Wall` | `[-Wunused-variable]` | 精选「有用、误报低」警告集。⚠️ 非「所有警告」 | +| `-Wextra` | `[-Wunused-parameter]` | 再补一批:未用参数、有符号/无符号比较 | +| `-Wpedantic` | `[-Wvla] ISO C++ forbids variable length array` | 按 ISO 标准查,对编译器扩展告警 → 更可移植 | + +演示:档1 → 档2 → 档3 警告逐级递增。 + +`-g` —— 生成 DWARF 调试信息塞进产物。实证: +- 无 `-g`:`not stripped`,0 个 `.debug*` 段; +- 有 `-g`:`with debug_info`,6 个 `.debug*` 段。 +有这些段 gdb/lldb 才能映射回源文件行号/变量名/类型。 + +## 易错点 + +1. **`-g` 不影响优化**。默认 `-O0`(不优化),`-g` 单加 = 可调试 + 不优化;发布再加 `-O2`。 +2. **这套标志 GCC/Clang 专用,MSVC 一套都不认**(`-Wall`/`-Wextra`/`-Wpedantic`/`-g` 全是 GCC/Clang 私有)。MSVC 有自己的对应物,本 lab 的 `CMakeLists.txt` 已用生成器表达式按编译器 ID 分挂: + + | GCC/Clang | MSVC | 含义 | + |----------|------|------| + | `-Wall -Wextra` | `/W4` | 警告级(W4 是 MSVC 实用最高档;`/Wall` 含义不同会刷屏,不可用) | + | `-Wpedantic` | `/permissive-` | 严格标准符合,禁用非标准扩展(精神接近,不完全等同) | + | `-g` | `/Zi` | 生成调试信息(写进 PDB) | + + `CMakeLists.txt` 第 29–32 行的真实写法是「分两条、按编译器 ID 选」: + + ```cmake + target_compile_options(smoke_catch2 PRIVATE + $<$:/W4;/permissive-;/Zi> + $<$>:-Wall;-Wextra;-Wpedantic;-g> + ) + ``` + + 关键取舍:第二条用 `$>` 而非枚举 `$` —— 「非 MSVC 即走 GCC/Clang 那套」,自动覆盖 Intel/LLVM 等兼容 GCC 标志的编译器,不必每加一个编译器改一次清单。生成器表达式里多条标志用分号 `;` 分隔(CMake 列表分隔符)。 diff --git a/code/volumn_codes/vol8-labs/ai/tiny_ml/stage0/tests/smoke.cpp b/code/volumn_codes/vol8-labs/ai/tiny_ml/stage0/tests/smoke.cpp new file mode 100644 index 000000000..a778df5d0 --- /dev/null +++ b/code/volumn_codes/vol8-labs/ai/tiny_ml/stage0/tests/smoke.cpp @@ -0,0 +1,6 @@ +#include +#include + +TEST_CASE("Smoke up the labs") { + std::print("Our smoke Test"); +} diff --git a/code/volumn_codes/vol8-labs/ai/tiny_ml/stage1/.clang-format b/code/volumn_codes/vol8-labs/ai/tiny_ml/stage1/.clang-format new file mode 100644 index 000000000..6c5b85aff --- /dev/null +++ b/code/volumn_codes/vol8-labs/ai/tiny_ml/stage1/.clang-format @@ -0,0 +1,110 @@ +# .clang-format 配置文件 +# 用于 clangd 格式化和代码检查 +# 生成日期: 2026-02-15 + +# 基础风格: LLVM (简洁现代) +BasedOnStyle: LLVM + +# 缩进设置 +IndentWidth: 4 +UseTab: Never +ColumnLimit: 100 + +# 大括号风格: 附加式 { 在语句末尾 +BreakBeforeBraces: Attach + +# 指针/引用星号位置: 左侧 (int* a) +PointerAlignment: Left +DerivePointerAlignment: false + +# 命名风格 (仅供参考,不影响已有代码) +# 可以让 clangd 警告不符合命名规范的代码 +# 需要配合 clang-tidy 使用 + +# 函数声明: 返回类型和函数名在同一行 +AlwaysBreakAfterReturnType: None +AlwaysBreakTemplateDeclarations: Yes + +# 结构体初始化: 按位置赋值 +# C++11UnifiedBraceList: false 使用传统风格 + +# 预处理器缩进: 与代码对齐 +IndentPPDirectives: AfterHash + +# 其他实用设置 + +# 在二元运算符后换行 +BreakBeforeBinaryOperators: None + +# 在三元运算符后换行 +BreakBeforeTernaryOperators: true + +# 连续赋值时的对齐 +AlignConsecutiveAssignments: false +AlignConsecutiveDeclarations: false + +# 转换类型周围的空格 +SpaceAfterCStyleCast: false + +# 逻辑运算符周围的空格 +SpaceAfterLogicalNot: false + +# 模板列表中的空格 +SpaceAfterTemplateKeyword: true + +# 控制语句括号内的空格: if (x) 而非 if (x ) +SpaceBeforeParens: ControlStatements +SpaceInEmptyParentheses: false + +# 圆括号内的空格 +SpacesInParentheses: false +SpacesInSquareBrackets: false + +# 容器类型周围的空格 +SpacesInContainerLiterals: false + +# lambda 相关 +SpaceBeforeAssignmentOperators: true +SpaceBeforeCpp11BracedList: false + +# 对齐 +AlignAfterOpenBracket: Align +AlignEscapedNewlines: Left +AlignOperands: true +AlignTrailingComments: true + +# 允许函数参数在同一行 +AllowAllArgumentsOnNextLine: true +AllowAllConstructorInitializersOnNextLine: true +AllowAllParametersOfDeclarationOnNextLine: true + +# 允许短函数在一行 +AllowShortBlocksOnASingleLine: Never +AllowShortCaseLabelsOnASingleLine: false +AllowShortFunctionsOnASingleLine: Inline +AllowShortIfStatementsOnASingleLine: Never +AllowShortLoopsOnASingleLine: false + +# 自动对齐注释 +ReflowComments: true + +# 最大连续空行数 +MaxEmptyLinesToKeep: 1 + +# 命名空间缩进 +NamespaceIndentation: None + +# 访问修饰符缩进 +IndentAccessModifiers: false +IndentCaseLabels: true + +# 换行符设置 +LineEnding: LF + +# C/C++ 语言设置 +Language: Cpp +Standard: Latest + +# 包含块排序 +SortIncludes: true +IncludeBlocks: Preserve diff --git a/code/volumn_codes/vol8-labs/ai/tiny_ml/stage1/CMakeLists.txt b/code/volumn_codes/vol8-labs/ai/tiny_ml/stage1/CMakeLists.txt new file mode 100644 index 000000000..d817a7d17 --- /dev/null +++ b/code/volumn_codes/vol8-labs/ai/tiny_ml/stage1/CMakeLists.txt @@ -0,0 +1,41 @@ +cmake_minimum_required(VERSION 3.20) + +project(tamcpp_mlinfra LANGUAGES CXX) + +set(CMAKE_CXX_STANDARD 23) +set(CMAKE_CXX_STANDARD_REQUIRED ON) + +set(CMAKE_EXPORT_COMPILE_COMMANDS ON) + +# 不调这个,CMake 不生成 CTestTestfile.cmake,catch_discover_tests 注册的 +# add_test 全悬空,ctest 永远 No tests found。 +enable_testing() + +message(STATUS "Ready to fetch the necessary test framework") + +include(FetchContent) + +FetchContent_Declare( + Catch2 + GIT_REPOSITORY https://github.com/catchorg/Catch2.git + GIT_TAG v3.5.0 +) + +FetchContent_MakeAvailable(Catch2) +list(APPEND CMAKE_MODULE_PATH ${catch2_SOURCE_DIR}/extras) + +add_library(TAMCPP_TinyML INTERFACE include/tinyml/tensor.hpp) +target_include_directories(TAMCPP_TinyML INTERFACE ${CMAKE_CURRENT_SOURCE_DIR}/include) + +# 跨编译器的 warning 选项封进一个函数,后续每个 target 一行调用就行, +# 不用在每个 CMakeLists.txt 里反复抄一遍 generator-expression。 +# 比较坑的点:这个 Lab 要同时吃 MSVC 和 GCC/Clang,两边选项根本不通用, +# 只能用 genex 按编译器分流——还得是跑到 Windows 下编译一轮才吃到这个亏! +function(tamcpp_target_warnings target) + target_compile_options(${target} PRIVATE + $<$:/W4;/permissive-;/Zi> + $<$>:-Wall;-Wextra;-Wpedantic;-g> + ) +endfunction() + +add_subdirectory(tests) diff --git a/code/volumn_codes/vol8-labs/ai/tiny_ml/stage1/include/tinyml/tensor.hpp b/code/volumn_codes/vol8-labs/ai/tiny_ml/stage1/include/tinyml/tensor.hpp new file mode 100644 index 000000000..af565003f --- /dev/null +++ b/code/volumn_codes/vol8-labs/ai/tiny_ml/stage1/include/tinyml/tensor.hpp @@ -0,0 +1,78 @@ +#pragma once +#include +#include +#include +#include + +namespace tamcpp::tinyml { + +template +class Tensor { + public: + enum class Error { + kShapeMismatch, + kOutOfRange, + }; + + constexpr std::expected at(std::size_t i, std::size_t j) noexcept { + if (i >= Rows || j >= Cols) { + return std::unexpected{Error::kOutOfRange}; + } + + return internals_[i * Cols + j]; + } + + constexpr std::expected at(std::size_t i, + std::size_t j) const noexcept { + if (i >= Rows || j >= Cols) { + return std::unexpected{Error::kOutOfRange}; + } + return internals_[i * Cols + j]; + } + + static_assert(Rows > 0 && Cols > 0, "We haven't seen a tensor with 0 size"); + + // Q: why not noexcept? + constexpr Tensor() = default; // Yeah, a default tensor + constexpr Tensor(std::array internals) + : internals_(std::move(internals)) {} + + /* These APIs are burden, yet we need this when we need to query the tensor metas */ + constexpr std::size_t row() const noexcept { return Rows; } + constexpr std::size_t col() const noexcept { return Cols; } + + // noted by Charliechen114514, Actually array::size_type :_, but I got myself lazy + constexpr std::size_t size() const noexcept { return internals_.size(); } + + // Hey! Get me! + constexpr StorageType& operator()(std::size_t i, std::size_t j) noexcept { + return internals_[i * Cols + j]; + } + + // Mark: + // functions overload differs in type params and const decorators + constexpr const StorageType& operator()(std::size_t i, std::size_t j) const noexcept { + return internals_[i * Cols + j]; + } + + // span view + constexpr std::span view() const noexcept { return internals_; } + constexpr std::span view() noexcept { return internals_; } + + constexpr std::array& storage() noexcept { return internals_; } + constexpr std::array& storage() const noexcept { + return internals_; + } + + private: + /** + * @brief Reason why we select one dimension is easy, cache friendly + * + */ + std::array internals_{}; // internal_arrays, one dimension +}; + +template +using Vector = Tensor<1, Cols, StorageType>; + +} // namespace tamcpp::tinyml diff --git a/code/volumn_codes/vol8-labs/ai/tiny_ml/stage1/tests/CMakeLists.txt b/code/volumn_codes/vol8-labs/ai/tiny_ml/stage1/tests/CMakeLists.txt new file mode 100644 index 000000000..27281fdd6 --- /dev/null +++ b/code/volumn_codes/vol8-labs/ai/tiny_ml/stage1/tests/CMakeLists.txt @@ -0,0 +1,16 @@ +# 顶层只把 Catch2 的 extras 加进了 MODULE_PATH,要用 catch_discover_tests +# 还得自己 include(Catch) 把命令加载进来。 +include(Catch) + +# 这个 Lab 会有好几个测试 target,依赖集合都一样 +# (Catch2 + TAMCPP_TinyML)+ 同一套跨编译器 warning + 都要注册到 ctest。 +# 封一层糖,每个测试一行起,不用反复抄,也避免手抄抄出 bug。 +function(tamcpp_add_test name source) + add_executable(${name} ${source}) + target_link_libraries(${name} PRIVATE Catch2::Catch2WithMain TAMCPP_TinyML) + tamcpp_target_warnings(${name}) + catch_discover_tests(${name}) +endfunction() + +tamcpp_add_test(smoke_catch2 smoke.cpp) +tamcpp_add_test(tensor_api tensor_api.cpp) diff --git a/code/volumn_codes/vol8-labs/ai/tiny_ml/stage1/tests/smoke.cpp b/code/volumn_codes/vol8-labs/ai/tiny_ml/stage1/tests/smoke.cpp new file mode 100644 index 000000000..a778df5d0 --- /dev/null +++ b/code/volumn_codes/vol8-labs/ai/tiny_ml/stage1/tests/smoke.cpp @@ -0,0 +1,6 @@ +#include +#include + +TEST_CASE("Smoke up the labs") { + std::print("Our smoke Test"); +} diff --git a/code/volumn_codes/vol8-labs/ai/tiny_ml/stage1/tests/tensor_api.cpp b/code/volumn_codes/vol8-labs/ai/tiny_ml/stage1/tests/tensor_api.cpp new file mode 100644 index 000000000..fd7896436 --- /dev/null +++ b/code/volumn_codes/vol8-labs/ai/tiny_ml/stage1/tests/tensor_api.cpp @@ -0,0 +1,49 @@ +#include "catch2/catch_test_macros.hpp" +#include "tinyml/tensor.hpp" +#include + +using namespace tamcpp::tinyml; + +TEST_CASE("dims are compile-time visible", "[tensor]") { + Tensor<4, 3> tensor; + static_assert(tensor.size() == 12); + static_assert(tensor.row() == 4); + static_assert(tensor.col() == 3); +} + +TEST_CASE("construct and access", "[tensor]") { + Tensor<2, 2> t(std::array{1.f, 2.f, 3.f, 4.f}); + REQUIRE(t(1, 0) == 3.f); +} + +TEST_CASE("row-major layout matches flat storage", "[tensor]") { + Tensor<2, 2> t(std::array{1.f, 2.f, 3.f, 4.f}); + for (std::size_t i = 0; i < 2; ++i) { + for (std::size_t j = 0; j < 2; ++j) { + REQUIRE(t(i, j) == t.storage()[i * 2 + j]); + } + } + REQUIRE(t.view().front() == t.storage()[0]); +} + +TEST_CASE("vector is a row tensor", "[tensor]") { + Vector<3> v; + static_assert(v.row() == 1 && v.col() == 3); +} + +TEST_CASE("out-of-range goes through expected", "[tensor]") { + Tensor<2, 2> t; + auto r = t.at(99, 99); + REQUIRE_FALSE(r); + REQUIRE(r.error() == Tensor<2, 2>::Error::kOutOfRange); + + // 单维越界也要拦(防 && 回归:i 越界但 j 在范围内) + auto r_single = t.at(99, 0); + REQUIRE_FALSE(r_single); + REQUIRE(r_single.error() == Tensor<2, 2>::Error::kOutOfRange); +} + +TEST_CASE("default construction is zero-initialized", "[tensor]") { + Tensor<2, 2> t; + REQUIRE(t(0, 0) == 0.f); +} diff --git a/documents/en/vol8-domains/ai/index.md b/documents/en/vol8-domains/ai/index.md new file mode 100644 index 000000000..e67cf264c --- /dev/null +++ b/documents/en/vol8-domains/ai/index.md @@ -0,0 +1,17 @@ +--- +title: "AI & TinyML" +description: "Build a toy neural-network inference engine from scratch in modern C++, and understand how TinyML inference actually runs on an MCU" +platform: host +tags: + - cpp-modern + - host + - intermediate +--- + +# AI & TinyML + +This subdomain collects TAMCPP's AI / TinyML hands-on labs. Like [Embedded Development](../embedded/) and Networking, this isn't the place for concept reference cards — it's for "build a working thing from scratch" projects. The goal is to express neural-network inference in modern C++ in a way that's clear, controllable, and explainable, while pulling `std::array` / `std::span` / `constexpr` / template dimension constraints / exception-free error handling / no heap allocation / static weights into one coherent piece. + +## Projects + +- [TinyInferCpp-Lab](./tiny_ml/) — Build a toy neural-network inference engine from scratch in C++23 (Input → Dense → ReLU → Dense → Argmax): float32, fixed structure, no heap / no exceptions / no RTTI on the core path. PC closure first, STM32 deployment as a follow-up. diff --git a/documents/en/vol8-domains/ai/tiny_ml/index.md b/documents/en/vol8-domains/ai/tiny_ml/index.md new file mode 100644 index 000000000..4656e5890 --- /dev/null +++ b/documents/en/vol8-domains/ai/tiny_ml/index.md @@ -0,0 +1,22 @@ +--- +title: "TinyInferCpp-Lab" +description: "A hands-on lab: build a toy neural-network inference engine from scratch in C++23" +platform: host +tags: + - cpp-modern + - host + - intermediate +--- + +# TinyInferCpp-Lab + +Build a toy neural-network inference engine from scratch in C++23 (Input → Dense → ReLU → Dense → Argmax): float32, fixed structure, no heap / no exceptions / no RTTI on the core path. For learning — not a replacement for TFLite Micro / STM32Cube.AI / CMSIS-NN / TinyMaix / NNoM / emlearn. + +## Articles + +- [Stage 0 · Project scaffold](./stage0/scaffold.md) — A standalone CMake23 project + a Catch2 smoke test; pour the toolchain foundation first. +- [Stage 1 · Fixed-dimension Tensor](./stage1/06-tensor.md) — A compile-time fixed-dimension, row-major, `std::array`-backed Tensor + `std::expected`. + +## What's next (in progress) + +Stage 2 (Dense layer & weight layout) → Stage 3 (ReLU / Argmax) → Stage 4 (DemoModel wiring) → Stage 5 (NumPy training & export) → Stage 6 (Python/C++ golden-test diff) → Stage 7 (embedded-friendliness audit) → Stage 8 (MCU porting plan). Companion project at `code/volumn_codes/vol8-labs/ai/tiny_ml/`. diff --git a/documents/en/vol8-domains/ai/tiny_ml/stage0/scaffold.md b/documents/en/vol8-domains/ai/tiny_ml/stage0/scaffold.md new file mode 100644 index 000000000..1db88ef41 --- /dev/null +++ b/documents/en/vol8-domains/ai/tiny_ml/stage0/scaffold.md @@ -0,0 +1,148 @@ +--- +title: "Project scaffold — pour the toolchain foundation" +description: "Stand up a standalone CMake23 project, pull Catch2 via FetchContent, and get a smoke test passing. Stage 0 writes zero lines of inference code — it only confirms the toolchain + build + test pipeline is wired up." +chapter: 8 +order: 6 +platform: host +difficulty: intermediate +cpp_standard: [23] +reading_time_minutes: 8 +prerequisites: + - "CMake basics" +tags: + - host + - cpp-modern + - intermediate + - CMake + - 工具链 + - 基础 +--- + +# Project scaffold — pour the toolchain foundation + +Stage 0 of TinyInferCpp-Lab does exactly one thing: stand up a standalone CMake project with a Catch2 smoke test that compiles and runs, plus a `.gitignore` that keeps build artifacts out. Not a single line of inference code — this stage only confirms the toolchain, the build system, and the test framework are wired up, so that every time you write code later, `cmake --build` gives you feedback. Companion project at `code/volumn_codes/vol8-labs/ai/tiny_ml/stage0/`. + +## Why not just start writing inference code + +Getting stuck in week one on "it won't compile / Catch2 won't pull / clangd isn't working" is what kills the project far more often than not knowing how to write some algorithm. Stage 0 clears those prerequisites up front, and forces you to confirm your toolchain actually supports C++23 right now — so you don't get to Stage 5 and discover your compiler is too old, when rolling back is expensive. + +## What the project looks like + +A standalone CMake project. The directory is just this: + +```text +stage0/ +├── CMakeLists.txt # standalone, FetchContent Catch2 v3.5.0 +├── .gitignore # build/ + .cache/ +├── tests/smoke.cpp # toolchain smoke test +└── logs/ # pitfall ledger (real evidence, see common pitfalls below) +``` + +## CMake skeleton + +The actual `CMakeLists.txt` looks like this, broken down in functional order: + +```cmake +cmake_minimum_required(VERSION 3.20) +project(tamcpp_mlinfra LANGUAGES CXX) + +set(CMAKE_CXX_STANDARD 23) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_EXPORT_COMPILE_COMMANDS ON) +``` + +The first three lines lock the standard. `CMAKE_CXX_STANDARD 23` with `STANDARD_REQUIRED ON` pins C++23 down so the compiler can't silently downgrade. Write `STANDARD` without `STANDARD_REQUIRED` and some compilers quietly drop to the highest standard they support — then you hit C++23 features later and get a baffling error you'd never trace back to the standard. `CMAKE_EXPORT_COMPILE_COMMANDS ON` makes CMake emit `compile_commands.json` for clangd; IDE jump-to-definition, completion, and diagnostics all depend on it. Leave it off and writing code hurts. + +```cmake +include(FetchContent) + +FetchContent_Declare( + Catch2 + GIT_REPOSITORY https://github.com/catchorg/Catch2.git + GIT_TAG v3.5.0 +) + +FetchContent_MakeAvailable(Catch2) +list(APPEND CMAKE_MODULE_PATH ${catch2_SOURCE_DIR}/extras) +``` + +This pulls Catch2 from Git at configure time and compiles it into the project via `add_subdirectory` — no preinstall, no submodule. The version is pinned by `GIT_TAG v3.5.0`. + +Each of the three steps does its own job. `FetchContent_Declare` only registers the name, source, and tag — it doesn't trigger a download. `FetchContent_MakeAvailable` is what clones on first configure, runs `add_subdirectory`, and defines the `Catch2::Catch2WithMain` target, along with a `_SOURCE_DIR` variable you can reference (the rule is depname lowercased, hence `catch2_SOURCE_DIR`). The last `list(APPEND CMAKE_MODULE_PATH .../extras)` line puts Catch2's helper `.cmake` files (`Catch.cmake` etc., which provide `catch_discover_tests`) onto CMake's module search path, so `include(Catch)` can find them later. + +Downloaded sources land in `build/_deps/catch2-src/`, intermediate products in `-build/`; a second configure doesn't re-pull, and your source tree stays clean. Real evidence for this mechanism — including how to rescue it when the pull fails — is in `logs/002-fetchcontent-catch2.md`. + +```cmake +add_executable(smoke_catch2 tests/smoke.cpp) +target_link_libraries(smoke_catch2 PRIVATE Catch2::Catch2WithMain) + +target_compile_options(smoke_catch2 PRIVATE + $<$:/W4;/permissive-;/Zi> + $<$>:-Wall;-Wextra;-Wpedantic;-g> +) +``` + +`target_compile_options` is modern CMake's target-based way: options hang off a specific target rather than polluting everything, unlike the old global `add_compile_options()`. `PRIVATE` means it only applies to compiling this target and doesn't propagate downstream. Warning flags are almost always PRIVATE — downstream doesn't care how you compile this, and warning flags are compiler-specific; make them `PUBLIC` and they explode the moment someone swaps compilers. + +The last two generator expressions deserve a breakdown. Conclusion first: `-Wall -Wextra -Wpedantic -g` are all GCC/Clang dialect — MSVC recognizes none of them. MSVC has its own set: warning level via `/W4` (the practical high setting; `/Wall` spams so hard it's unusable), strict conformance via `/permissive-` (disables non-standard extensions), debug info via `/Zi` (into a PDB). So the project splits by compiler ID: MSVC takes one line, non-MSVC takes the other. + +The second line is deliberately written as `$>` rather than enumerating `$`. The former means "anything that isn't MSVC takes the GCC/Clang set", automatically covering Intel, LLVM, and whatever else understands `-Wall`, without you having to edit the list every time a new compiler shows up. The full flag comparison is in `logs/003-target-compile-options.md`. + +## Why C++23 and not C++20 + +Stage 0 itself doesn't depend on any C++23 feature — you could run this stage on C++20. But from Stage 1 on you'll want `consteval`, fuller `constexpr`, and `std::expected`, so set the standard to 23 now and save yourself a backport later. + +## Smoke test: pick a meaner probe + +```cpp +#include +#include + +TEST_CASE("Smoke up the labs") { + std::print("Our smoke Test"); +} +``` + +The point of a smoke test is to prove the chain works, so picking a meaner probe pays off. Using `std::print` here is deliberate — it needs the C++23 `` header to be reachable, so a single line simultaneously stresses the toolchain, Catch2, CMake, and the C++23 standard library. It probes deeper than an honest `REQUIRE(1 + 1 == 2)`. + +## Verification + +```bash +cmake -S . -B build # first-time FetchContent pulls Catch2, needs network +cmake --build build -j +./build/smoke_catch2 +``` + +Step 3 prints `Our smoke Test` and reports `All tests passed`. Then manually confirm in the IDE that clangd can jump into `smoke.cpp` and autocomplete Catch2 macros — that's how you know `compile_commands.json` actually took effect. + +## Common pitfalls + +::: warning FetchContent can't pull Catch2 +WSL's access to GitHub is flaky; the failure log usually reads `Failed to connect to github.com port 443`. The rescue is a manual shallow clone, swapped in as a pre-placed directory to replace the automatic download: + +```bash +git clone --depth 1 -b v3.5.0 https://github.com/catchorg/Catch2.git /tmp/catch2 +cmake -S . -B build -DFETCHCONTENT_SOURCE_DIR_CATCH2=/tmp/catch2 +``` + +The variable name rule is `FETCHCONTENT_SOURCE_DIR_` — it essentially replaces the `GIT_REPOSITORY` download source. Evidence in `logs/002`. +::: + +::: warning Compiler doesn't support C++23 +`set(CMAKE_CXX_STANDARD 23)` needs GCC 13+ / Clang 16+ / MSVC VS2022 17.6+. Self-test before you start work: + +```bash +g++ --version +echo 'int main(){return 0;}' | g++ -std=c++23 -x c++ - -o /tmp/cxx23_smoke && echo "C++23 OK" +``` + +Locally tested with g++ 16.1.1 and clang++ 22.1.6 (see `logs/003`). If it errors, upgrade the compiler — don't downgrade the standard; from Stage 1 on, the lower standard won't hold up. +::: + +::: warning clangd reports "header not found" +Nine times out of ten `compile_commands.json` wasn't generated, or clangd isn't pointed at `build/`. Confirm `CMAKE_EXPORT_COMPILE_COMMANDS ON` is set. `.cache/clangd` is clangd's index storage — don't delete it by accident and don't commit it; it's already in `.gitignore`. +::: + +::: warning Running cmake at the repo root +Don't run cmake at the repo root. This project builds inside its own `stage0/` directory; `build/` artifacts stay right there and are already `.gitignore`d. +::: diff --git a/documents/en/vol8-domains/ai/tiny_ml/stage1/01-what-is-tensor.md b/documents/en/vol8-domains/ai/tiny_ml/stage1/01-what-is-tensor.md new file mode 100644 index 000000000..e73597fff --- /dev/null +++ b/documents/en/vol8-domains/ai/tiny_ml/stage1/01-what-is-tensor.md @@ -0,0 +1,46 @@ +--- +title: "What is a Tensor — take the name off its pedestal" +description: "Demystify: in our Lab a Tensor is just a fixed-size 2D table of floats, backed by a contiguous std::array with a 2D-access shell around it. Nothing mystical." +chapter: 8 +order: 7 +platform: host +difficulty: beginner +cpp_standard: [23] +reading_time_minutes: 4 +related: + - "The fixed-dimension Tensor — the inference engine's data foundation" + - "What a Tensor holds in a neural network" +tags: + - host + - cpp-modern + - beginner + - 基础 +--- + +# What is a Tensor — take the name off its pedestal + +Oh, you mean Tensor? I asked around, and maybe we can just say — an N-dimensional array. + +Hold on, hold on! What "N-dimensional array"? Don't make your Tensor laugh. + +Alright, in math there really is such a thing as a "tensor", and it comes with a scary progression — allow me to flip through my linear-algebra textbook: zero-dimensional is a scalar (one number), one-dimensional is a vector (a column of numbers), two-dimensional is a matrix (a table of numbers), and only from three dimensions up is it officially called a tensor. So strictly speaking, a two-dimensional thing should be called a matrix, not a tensor. + +But I noticed the deep-learning crowd isn't nearly that pedantic. PyTorch and TensorFlow call any "multi-dimensional array" a Tensor, regardless of how many dimensions. Once that usage caught on, "Tensor" became the generic term for "the container that holds numbers in a neural network." Our Lab keeps the word purely because it's the most common industry vocabulary — when you read other materials or other frameworks later, it'll match up. + +## What it actually is, in our Lab + +Strip away all the halo, and a Tensor is just a **fixed-size 2D table**, with a float in every cell. + +Huh — if that's a bit of a stretch for you, hmm, maybe you need to go back to the basics (I mean volume one of this project) and shore them up. I find it helpful to think of it as an Excel sheet: `Rows` rows, `Cols` columns, one float per cell. Want to look at row 2, column 3? Just locate that cell. That's the whole thing. If `Rows = 1`, it collapses into a single row — that's a vector, and as we'll see, inputs, biases, and outputs all take this "one row" form. + +Strip it down to the bottom and it's just a contiguous array of floats of length `Rows * Cols`, laid out obediently in a single line in memory. The Tensor shell wrapped around it is, in essence, a wrapper: you write `t(i, j)` to access row i, column j, and it converts that into "which position along this line", then fetches it for you. The conversion rule (row-major) is the business of [04](./04-row-major.md), so we won't expand on it here. For now, just hold one picture in mind: **two-dimensional logical coordinates, a one-dimensional contiguous storage underneath, connected by a conversion formula.** + +So "build a Tensor", put plainly, means building that shell: a fixed-size contiguous storage, plus a 2D-access syntax around it. A fixed-size contiguous storage maps naturally to `std::array` on the C++ side; the rest of the work is wiring the conversion into `operator()(i, j)`. The second half of Stage 1 does exactly that. + +## Then why not just use a ready-made 2D array + +At this point the C and C++ veterans burst out laughing: Hah? You're kidding me — we've *always* had the 2D array `float[4][3]`, and if you say it's not modern enough, we've got a second line of defense: `std::array, 4>`. Can't we just use those directly? Do we have to roll our own Tensor? I'd say you just love reinventing wheels. + +Stop. My answer is: you *could* use them, but they're awkward to work with, and they don't line up with the hard constraints we'll keep later (no heap, diff against NumPy, shape visible at compile time). Exactly where they're awkward and how they don't line up is what [03](./03-why-not-built-in.md) takes apart. + +Leave this piece with one picture: **a Tensor is a fixed-size 2D table, with a contiguous float array underneath, wrapped in a 2D-access shell.** Hold onto that, and in the [next piece](./02-tensor-in-neural-network.md) we'll see what a Tensor actually holds inside a neural network — what numbers sit in those cells. diff --git a/documents/en/vol8-domains/ai/tiny_ml/stage1/02-tensor-in-neural-network.md b/documents/en/vol8-domains/ai/tiny_ml/stage1/02-tensor-in-neural-network.md new file mode 100644 index 000000000..c38ca53ec --- /dev/null +++ b/documents/en/vol8-domains/ai/tiny_ml/stage1/02-tensor-in-neural-network.md @@ -0,0 +1,90 @@ +--- +title: "What a Tensor holds in a neural network — four kinds of data, one container" +description: "Tear down a Dense layer and look at the shapes of four Tensors: input, weights, bias, output. Every number flowing through a neural network is a Tensor; a vector is just a Tensor with Rows=1." +chapter: 8 +order: 8 +platform: host +difficulty: beginner +cpp_standard: [23] +reading_time_minutes: 6 +prerequisites: + - "What is a Tensor — take the name off its pedestal" +related: + - "The fixed-dimension Tensor — the inference engine's data foundation" +tags: + - host + - cpp-modern + - beginner + - 基础 +--- + +# What a Tensor holds in a neural network — four kinds of data, one container + +The previous piece demystified a Tensor into "a 2D table of numbers." So what does that table actually hold in our neural network? + +The answer might come as a relief: **almost everything**. Every number flowing through a neural network, from start to finish, lives inside a Tensor. The input is a Tensor, the trained parameters are Tensors, every intermediate result computed along the way is a Tensor. So-called "neural-network inference", **stripped to the bottom, is a computation of Tensor in, Tensor out.** + +First let's set the scene for our own Lab, otherwise it's too abstract. What we're building is a little machine that reads sensor values and judges device state: feed it three readings — temperature, humidity, light — and it spits out a verdict: normal, warning, or danger. That concrete a thing. Every number flowing through this little machine from end to end is a Tensor, and this piece takes apart what kinds live around it. + +To anchor that picture, let's tear down a Dense layer (fully-connected layer — I know you might be confused again, just pretend it's some mysterious little word, something like a function "A") and see what kinds of Tensors sit around it. Dense is the only arithmetic layer in our MLP; Stage 2 is devoted entirely to writing it, so we won't touch how it computes here — we only look at what numbers it has on hand. + +What numbers does it actually have on hand? You don't need to trace how function "A" transforms things in the middle, but for it to do any work, it has to be storing something internally — otherwise why would the same input always produce the same output. What it stores boils down to two things. + +One says how many inputs each output should gather and what proportions to mix them together before summing — those proportions are the **weights**, think of them as a recipe table. The other is a constant each output adds on at the very end, called the **bias** — like giving the fader one last push at the mixing desk. + +## A Dense layer's four Tensors + +Concretely, for the first layer, `Dense(3, 4)` means kneading 3 inputs into 4 intermediate values. Around this layer there are four Tensors: + +| Data | What it is | Shape | Our notation | +|---|---|---|---| +| Input x | three sensor readings | a vector of length 3 | `Tensor<1, 3>` | +| Weights W | coefficients kneading 3 inputs into 4 outputs | 4 rows × 3 columns | `Tensor<4, 3>` | +| Bias b | a constant each output adds extra | a vector of length 4 | `Tensor<1, 4>` | +| Output y | the 4 intermediate values this layer computes | a vector of length 4 | `Tensor<1, 4>` | + +Terms flying at your face — let's slow down. + +**Input** is the three readings fed in — one temperature, one humidity, one light. It's naturally "a row of 3 numbers", so it's `Tensor<1, 3>` — 1 row, 3 columns. Neural networks have a special name for this "one row" form: a vector. We use the alias `Vector` to express it; under the hood it's still `Tensor<1, N>`. + +**Weights** are what this layer has truly "learned" — the most central parameters in the whole network. It's a 4×3 table: 4 rows for 4 outputs, 3 numbers per row for 3 inputs. The 3 numbers in row i are the coefficients used to compute output i. How those coefficients get multiplied against the input and summed is Stage 2's job; here you only need to accept "it's a 4×3 table, rows map to outputs, columns map to inputs". + +**Bias** is a constant each output adds extra — 4 outputs, so a vector of length 4. Its job is to give each output an independent shift; pure addition, no input involved. Weights decide "how the inputs get mixed"; bias decides "how much the whole thing gets lifted after mixing". + +**Output** is the 4 intermediate values this layer computed, shaped the same as the bias — a vector of length 4. These 4 numbers feed the next layer (first through a ReLU that zeros out negatives, then into the second Dense layer), propagating onward. + +Those are the four Tensors. Notice — **vectors and matrices are both expressed with the same `Tensor`**, a vector being just the special case `Rows = 1`. This is a deliberate unification in our Tensor design: one container holds every shape of data in a neural network, no need to build two separate ones for vectors and matrices. + +## The whole pipeline is all Tensors + +Zoom out to the whole pipeline and the "one container" intuition gets clearer. Our MLP from input to output: + +```mermaid +flowchart LR + A(["Input Tensor 1×3"]) --> B["Dense"] + B --> C(["Tensor 1×4"]) + C --> D["ReLU"] + D --> E(["Tensor 1×4"]) + E --> F["Dense"] + F --> G(["Tensor 1×3"]) + G --> H["Argmax"] + H --> I(["Class
Normal/Warning/Danger"]) +``` + +Every stage's data is a Tensor. The first Dense takes a 1×3 Tensor and emits a 1×4; ReLU turns every negative in the Tensor into zero, shape unchanged; the second Dense turns 1×4 into 1×3; finally Argmax picks the largest of those 3, and its position (0/1/2) is the classification result — normal, warning, danger. + +So what's each later stage doing? All of them are doing *something* with Tensors: + +- Stage 2's Dense takes the input Tensor and the weights Tensor, multiplies and adds, adds the bias Tensor, produces the output Tensor +- Stage 3's ReLU zeros out every negative in a Tensor +- Stage 3's Argmax finds the position of the largest number in a Tensor + +Once the Tensor is fixed, every later stage is just operations on top of it. That's also why we spend a whole Stage 1 getting the Tensor solid: if it collapses, everything after has to be rewritten. + +## Then why not hold these in a ready-made container + +You should be asking again: since it's just holding a few floats, can't we use `std::vector` for the input and `std::vector>` for the weight matrix? + +The answer to that is the same one as "why not a ready-made 2D array" at the end of the previous piece. It's tied to the hard constraints our v0.1 has to keep, and it deserves its own teardown. [03](./03-why-not-built-in.md) puts `std::vector`, nested `std::array`, and the native 2D array on trial one by one, and explains why in the end we have to build this Tensor ourselves. + +Take two things away from this piece: **the input, weights, bias, and output flowing through a neural network are all Tensors, and a vector is just a Tensor with `Rows = 1`**; and **every later stage is an operation on top of Tensors**. With that foundation, when you go poke at the Tensor's design trade-offs, you'll know what each trade-off serves. diff --git a/documents/en/vol8-domains/ai/tiny_ml/stage1/03-why-not-built-in.md b/documents/en/vol8-domains/ai/tiny_ml/stage1/03-why-not-built-in.md new file mode 100644 index 000000000..5b6024109 --- /dev/null +++ b/documents/en/vol8-domains/ai/tiny_ml/stage1/03-why-not-built-in.md @@ -0,0 +1,80 @@ +--- +title: "Why not use what's already there — three suspects on trial" +description: "Put std::vector, the native 2D array, and nested std::array in the dock and see which v0.1 hard constraint each dies on, forcing out the minimal design: flat array + index mapping + dimensions as template parameters." +chapter: 8 +order: 9 +platform: host +difficulty: intermediate +cpp_standard: [23] +reading_time_minutes: 7 +prerequisites: + - "What a Tensor holds in a neural network — four kinds of data, one container" +related: + - "The fixed-dimension Tensor — the inference engine's data foundation" + - "Row-major — how a 2D coordinate lands in 1D memory" + - "Shape baked into the type — why dimensions are template parameters" +tags: + - host + - cpp-modern + - intermediate + - 内存管理 + - 基础 +--- + +# Why not use what's already there — three suspects on trial + +The last two pieces kept saying "we're going to build our own Tensor" without ever answering the sharpest question head-on: **C++ already has a pile of number-holding containers — why not use them, why roll our own?** + +This piece takes that question apart. We put the three most ready-made candidates in the dock, try them one by one, and see which hard constraint each dies on. Walk through it once and you'll see our Tensor wasn't invented out of thin air — it was forced out by all those "doesn't work" answers. + +First recall what it has to satisfy (the v0.1 hard constraints): + +- No allocation on the core inference path (no `new` / `malloc`) +- The Tensor doesn't use `std::vector` +- Size fixed at compile time +- Weights must sit as `inline constexpr std::array` +- And the memory layout has to match NumPy's, for the Stage 6 diff + +With that checklist in hand, the trial begins. + +## Suspect one: `std::vector` (nested included) + +I'm sure everyone's first reaction is this one, right — the most direct choice. `std::vector` for the input, `std::vector>` for the weight matrix; dynamic size, comfortable to use. + +Where it dies: first, `std::vector` is dynamically allocated internally — elements live on the heap, and construction calls `new` (usually via an allocator), which runs straight into the "no allocation on the core path" constraint. On an MCU, every inference allocating and freeing a pile of small blocks means fragmentation and uncontrollable latency — exactly what embedded work abhors. Second, its size is runtime — `v.size()` isn't known at compile time, which fails "size fixed at compile time". Third, if you use nested `vector>` for a matrix, it's worse: every row is an independent vector with its own heap block, memory isn't contiguous, pointers jump all over the place on access, cache hits go to rot, and N rows means N heap allocations. + +So the Lab straight-up blacklists `std::vector` in the hard constraints: "the Tensor doesn't use `std::vector`". Not because it's unusable, but because on our track it has no business showing up. + +## Suspect two: the native 2D array `float[Rows][Cols]` + +```cpp +float weight[4][3]; +``` + +This one doesn't allocate (stack or static), size is fixed at compile time — looks like it satisfies two constraints. + +Where it dies: the C-style array is a half-finished product. First, the moment you pass it into a function it decays into a pointer, dimension info gone on the spot — inside `f(float w[4][3])` you can't get at that 4 and 3, just a bare pointer, and every bounds check has to be hand-written. Second, it has no API at all; to get row/column counts you dodge through `sizeof(weight)/sizeof(weight[0])`, annoying every time. Third, the things we want later — bounds checking (`at()` returning `std::expected`), a flat view (`flat()` returning `std::span`), the contiguous-memory guarantee for diffing against NumPy — it carries none of them, all of it has to be wrapped in an outer layer. + +Wrapping it is fine, but if you're going to wrap it anyway, what sits underneath is worth rethinking. Which brings up the third candidate. + +## Suspect three: nested `std::array, Rows>` + +```cpp +std::array, 4> weight; +``` + +This is the most proper ready-made way to write a "fixed-size 2D array" in C++. `std::array` doesn't allocate, size is fixed at compile time, memory is contiguous (nested array's overall memory is contiguous too, since the inner array is POD). It patches the "dimension decay" hole the second candidate has, because `std::array` is a proper type — pass it by value or by reference, the dimensions travel with it. + +Where it dies: it *works* — it's the closest of the three — but it's awkward. First, access is two-level `weight[i][j]`, not the unified `weight(i, j)` interface we want, and it doesn't match NumPy's `W[i, j]` style either. Second, you have to dig the shape out of the type: in `std::array, 4>` the outer is 4 (rows), the inner is 3 (columns) — read backwards, easy to flip, and a pain to extract with template metaprogramming. Third and most lethal: the flat contiguous view we want later (`std::span`), diffing against NumPy `flatten()`, the `at()` bounds check — nested array gives none of them directly; you have to manually flatten, manually wrap. + +Do the math: rather than use nested array and then spend another lap bolting on a flat view, a unified `(i, j)` interface, a row-major guarantee, and error handling — all that wrapper work adds up to about the same as building a Tensor from scratch. And nested array takes a detour on the "flat contiguous" front: the data is inherently one-dimensional, it insists on laying it out as 2D nesting, and then you flatten it back. + +## None of the three works — a design gets forced out + +Lay the fates of the three candidates side by side and a common thread shows: **what we need is, in essence, a fixed-size contiguous float storage plus a usable 2D access on top.** The three candidates either get the storage wrong (vector on the heap, and dynamically sized) or leave the access layer too rough (native arrays and nested array neither give you `(i, j)`, `flat()`, `at()`). + +So go straight for the essence. Storage is `std::array` — a fixed-size contiguous memory, compile-time sized, no heap allocation, naturally matching `inline constexpr std::array` weights. Access wraps a layer on top: `operator()(i, j)` turns the 2D coordinate into a 1D index, and for the cases that need checking, a separate `at()` goes through `std::expected`. Dimensions Rows and Cols go straight into the template parameters — baked into the type, visible at compile time, and as a bonus the type system catches shape errors for you. + +These three pieces get picked up by the next few pieces: how a 2D coordinate maps to a 1D index (row-major) is [04](./04-row-major.md); what's good about dimensions as template parameters is [05](./05-shape-in-type.md); what the full interface looks like and how to write it is [06-tensor.md](./06-tensor.md). + +And there it is — where this Tensor design came from is clear now: not someone's brainstorm, but the minimal shape forced out after stepping on every downside of the ready-made options. diff --git a/documents/en/vol8-domains/ai/tiny_ml/stage1/04-row-major.md b/documents/en/vol8-domains/ai/tiny_ml/stage1/04-row-major.md new file mode 100644 index 000000000..48979f144 --- /dev/null +++ b/documents/en/vol8-domains/ai/tiny_ml/stage1/04-row-major.md @@ -0,0 +1,80 @@ +--- +title: "Row-major — how a 2D coordinate lands in 1D memory" +description: "Tear apart the math behind operator()(i,j): a 2D coordinate lands in 1D memory as i*Cols+j, one row at a time. This order matches NumPy's default C order and C++ native arrays — the foundation of the Stage 6 diff." +chapter: 8 +order: 10 +platform: host +difficulty: intermediate +cpp_standard: [23] +reading_time_minutes: 5 +prerequisites: + - "Why not use what's already there — three suspects on trial" +related: + - "The fixed-dimension Tensor — the inference engine's data foundation" + - "Shape baked into the type — why dimensions are template parameters" +tags: + - host + - cpp-modern + - intermediate + - 内存管理 +--- + +# Row-major — how a 2D coordinate lands in 1D memory + +[The previous piece](./03-why-not-built-in.md) set the direction: storage is a flat `std::array`, with an `operator()(i, j)` shell on top doing coordinate conversion. This piece tears that conversion apart: **given a 2D coordinate (i, j), how do you compute its position in that 1D memory?** + +You might think, it's just an index calculation, how hard can it be. It's not hard, but it has a name you need to meet first: row-major. The name sounds intimidating, but at bottom it's just a convention about "what order to lay things out in". Get this convention wrong and your NumPy diff blows up in your face later. + +## Copy it down one row at a time + +First make the problem concrete. Say there's a 2-row, 3-column matrix; logically you see a 2D table: + +```text + col0 col1 col2 +row0 [ a00 a01 a02 ] +row1 [ a10 a11 a12 ] +``` + +But memory is one-dimensional — a single line, it doesn't know rows from columns. How does this table fit into a line? + +The most natural approach, and what you'd do hand-copying a table: **copy the first row end to end, then the second row.** So in memory it looks like this: + +```text +index: 0 1 2 3 4 5 + [ a00, a01, a02, a10, a11, a12 ] + └──── row 0 ────┘ └──── row 1 ────┘ +``` + +That's row-major — in plain words, "row-first": finish one row, then the next. `a00` lands at index 0, `a02` at index 2, `a10` right after `a02` at index 3, because it's at the very start of the second row and has to wait for the three numbers of the first row to settle before it gets its turn. + +## The formula: i * Cols + j + +Picture in hand, now write the conversion. Given coordinate (i, j), count how many slots are already taken ahead of it. + +Ahead of row i, i full rows are already packed in, each Cols slots, so that's `i * Cols` slots. Inside row i, you walk right another j slots to reach column j. Add both ends: + +```text +flat_index = i * Cols + j +``` + +That's the whole line. The entire secret of `operator()(i, j)` is right here: `return data_[i * Cols + j];`. + +Verify it with our Lab's `Tensor<4, 3>` — Rows=4, Cols=3. Accessing `t(2, 1)`, i.e. row 2, column 1: ahead of it sit 2 full rows, 3 slots each, 6 slots total; then inside row 2 walk right 1 more slot, add 1, landing at index 7. So `t(2, 1)` is `data_[7]`. Count against the picture above — index 7 does sit at row 2, column 1 (counting from 0), checks out. + +## Why row-major and not column-major + +There's more than one way to store it. You could copy column by column too — fill all rows of column 0, then column 1 — that's column-major, with the formula `j * Rows + i`. Fortran, MATLAB, and some BLAS implementations are column-major; the old hands of scientific computing use it a lot. + +So why did we pick row-major? Two reasons, both hard. + +First, C and C++ native 2D arrays are row-major to begin with. `float w[4][3]` in memory is `w[0][0], w[0][1], w[0][2], w[1][0], ...`, one row after another. C++ programmers know this order best — no mental transposition needed. + +Second, and more importantly, NumPy's default C order is row-major. `np.array([[1,2,3],[4,5,6]])` with a `.flatten()` call yields `[1,2,3,4,5,6]`, identical to our `data_[i*Cols+j]` order. That means when Stage 5 exports weights from NumPy, Python's `W[i, j]` and C++'s `t(i, j)` point at the same number, and the Stage 6 diff can compare digit by digit. + +If either side sneakily used the other order, the diff would be wrong end to end — you'd take C++'s row-major `data_[7]` to compare against some position in NumPy's column-major layout, and nothing would match, debug-into-doubt-your-life. So this layout gets nailed down right here, and every later stage is forbidden from changing its mind. + +## What to take away + +Row-major is just this much. Don't let its simplicity fool you — it's the alignment foundation of the whole Lab; whether Python and C++ can match up hinges entirely on it. Remember two things: a 2D coordinate (i, j) sits in 1D memory at `i * Cols + j`, one row filled before the next; and this order matches NumPy's default and C++ native arrays. + +[The next piece](./05-shape-in-type.md) is about dimensions: why Rows and Cols go into the template parameters — baked into the type — instead of being passed into a constructor. diff --git a/documents/en/vol8-domains/ai/tiny_ml/stage1/05-shape-in-type.md b/documents/en/vol8-domains/ai/tiny_ml/stage1/05-shape-in-type.md new file mode 100644 index 000000000..cb59956b1 --- /dev/null +++ b/documents/en/vol8-domains/ai/tiny_ml/stage1/05-shape-in-type.md @@ -0,0 +1,89 @@ +--- +title: "Shape baked into the type — why dimensions are template parameters" +description: "Why dimensions are template parameters Tensor rather than constructor arguments: compile-time fixed size comes with no heap allocation, the type system acts as a free shape-checker moving shape errors to compile time, and dimensions are visible at compile time. The cost: every dimension combination is a distinct type." +chapter: 8 +order: 11 +platform: host +difficulty: intermediate +cpp_standard: [23] +reading_time_minutes: 6 +prerequisites: + - "Row-major — how a 2D coordinate lands in 1D memory" +related: + - "The fixed-dimension Tensor — the inference engine's data foundation" + - "Why not use what's already there — three suspects on trial" +tags: + - host + - cpp-modern + - intermediate + - 模板 + - 类型安全 +--- + +# Shape baked into the type — why dimensions are template parameters + +[The previous piece](./04-row-major.md) covered "how the numbers are laid out" (row-major). This piece covers the last piece: **why do those two numbers, Rows and Cols, have to be written into the template parameters `Tensor`, instead of being passed in at construction like a normal object?** + +You might think, dimensions, just pass rows and cols at construction and be done with it — why bake them into the template parameters and sprout a pile of types. This step is the one most easily skipped by beginners, and yet the most valuable — it moves a whole category of "shape wrong" bugs from runtime to compile time. + +## The two ways, side by side + +First look at how it'd be written if dimensions didn't go into the type. Roughly this beginner-friendly form: + +```cpp +class Tensor { + float* data_; + int rows_, cols_; +public: + Tensor(float* data, int rows, int cols) + : data_(data), rows_(rows), cols_(cols) {} +}; +``` + +Size is a runtime `int` member, passed in at construction. This is how the vast majority of "dynamic array" classes are written; PyTorch's `torch::Tensor` is like this — shape known only at runtime. + +Our way is: + +```cpp +template +class Tensor { + std::array internals_{}; + // rows_/cols_ don't exist; they ARE the template parameters Rows, Cols +}; +``` + +Size isn't a member — it's part of the type. `Tensor<4, 3>` and `Tensor<2, 2>` are two completely different types, fixed at compile time, unchangeable at runtime. + +## Benefit one: compile-time fixed size, and no heap allocation along with it + +Since Rows and Cols are compile-time constants, `Rows * Cols` is a compile-time constant too, and the size of `std::array` is fixed at compile time. The compiler knows how big this object is and lays it out directly on the stack or in static storage — no `new`, no `malloc`. The "compile-time fixed size" and "no heap allocation" constraints are both satisfied in one step. + +Conversely, that `float* data_` in the runtime version — where does data point? Either at a heap block from `new` (violates "no heap allocation"), or at an externally-passed buffer (lifetime is on you, dangling references waiting to happen). Both roads have pits. + +## Benefit two: the type system catches shape errors for you (the most valuable part) + +This is where dimensions-in-the-type truly pays off. A huge class of errors in neural networks is, at root, shape errors: the weight matrix's column count doesn't match the input vector's length, two matrices have mismatched dimensions for multiplication, and so on. If shape is runtime, these can only be checked at runtime — you find out when it crashes or returns an error code. But if shape is part of the type, **the compiler can hold off a whole swath of them at compile time.** + +Example. A Dense layer requires the weight matrix's column count to equal the input's length: weights W are `Tensor<4, 3>`, input x must be `Tensor<1, 3>` (that 3 has to match). If one day you slip and pass in a `Tensor<1, 5>` input, the compiler rejects it at compile time — the program never even gets to run. + +This is something dynamic-shape frameworks can't give you. PyTorch's runtime shape means a dimension mismatch has to wait until that line of code runs and throws a RuntimeError. Baking the dimensions into the type turns the type system into a free shape-checker. + +The specific "how to bake it" — Stage 2 will use `static_assert` and template constraints to pin down dimension relationships when writing Dense, and you'll see it block errors at compile time. For this piece you only need to accept the conclusion: **dimensions in the type, and a whole category of shape errors moves from runtime to compile time.** + +## Benefit three: dimensions visible at compile time + +A less flashy but quite real side benefit. Since Rows and Cols are compile-time constants, the query functions `row()`, `col()`, `size()` can evaluate at compile time — we mark them `constexpr`, which is enough; writing `static_assert(tensor.size() == 12)` just works. If you really want to force "compile-time only", reach for `consteval` — Stage 1 has no such need. + +This also pays off in Stage 5: weights live as `inline constexpr std::array`, and their shape has to be a compile-time-known constant — which lines up exactly with the Tensor's "dimensions in the type" design. The two sides mesh naturally. + +## The cost: dimension combinations are type combinations + +Honest about the cost. Dimensions in the type means **every dimension combination is a distinct type.** `Tensor<4, 3>`, `Tensor<3, 4>`, `Tensor<2, 2>` are three mutually distinct types; when writing function templates they're different instantiations. + +For our Lab this isn't a problem: the MLP's shapes are a fixed few (input 1×3, weights 4×3 and 3×4, biases 1×4 and 1×3), countable, type bloat contained. But if you were writing a general framework that accepts arbitrary shapes and even reshapes dynamically, this "dimensions in the type" wouldn't be enough — you'd have to go back to runtime shape, which is what PyTorch chose. We trade "fixed shape" for "compile-time shape safety"; the deal is worth it for a teaching Lab, not worth it for a general framework. Horses for courses. + +## The five-piece intro, complete + +Looking back at these five pieces: [01](./01-what-is-tensor.md) demystifies a Tensor into a 2D table, [02](./02-tensor-in-neural-network.md) sees it hold input/weights/bias/output, [03](./03-why-not-built-in.md) rejects three ready-made suspects and forces out a design, [04](./04-row-major.md) nails down the row-major layout, and this piece bakes the dimensions into the type. What a Tensor is, what it holds, why it's built this way — the five pieces of the puzzle are complete. + +Next is [06-tensor.md](./06-tensor.md): lay out the full interface, cover the three remaining small decisions (at returns a value not a reference, fixed 2D without variadic templates, how to add the library in CMake), then write it following the interface sketch. All the groundwork from these five pieces is so that the design trade-offs in 06-tensor.md don't read like they're hanging in mid-air. diff --git a/documents/en/vol8-domains/ai/tiny_ml/stage1/06-tensor.md b/documents/en/vol8-domains/ai/tiny_ml/stage1/06-tensor.md new file mode 100644 index 000000000..4ebfbe92d --- /dev/null +++ b/documents/en/vol8-domains/ai/tiny_ml/stage1/06-tensor.md @@ -0,0 +1,220 @@ +--- +title: "The fixed-dimension Tensor — the inference engine's data foundation" +description: "Build a compile-time fixed-dimension, row-major, std::array-backed Tensor, with at going through std::expected returning a value for exception-free error handling. Three design decisions: at returns a value not a reference, fixed 2D, row-major. Companion project at code/volumn_codes/vol8-labs/ai/tiny_ml/stage1/" +chapter: 8 +order: 12 +platform: host +difficulty: advanced +cpp_standard: [23] +reading_time_minutes: 12 +prerequisites: + - "Project scaffold — pour the toolchain foundation" + - "Templates and non-type parameters" +tags: + - host + - cpp-modern + - advanced + - 模板 + - 内存管理 + - 类型安全 +--- + +# The fixed-dimension Tensor — the inference engine's data foundation + +Stage 1 builds the entire inference engine's data foundation: a compile-time fixed-dimension, row-major, `std::array`-backed `Tensor`, plus exception-free error handling based on `std::expected`. Finish this stage and you can express sensor input as `Tensor<1, 3>` and Dense weights as `Tensor<4, 3>` — though Dense itself waits for Stage 2. Companion project at `code/volumn_codes/vol8-labs/ai/tiny_ml/stage1/`. + +This is Stage 1's implementation main doc. If the Tensor concept itself, or why we have to build our own, is still unfamiliar, read the [five intro pieces](./index.md) first and come back; if you already know what a Tensor is and why it's designed this way, just keep reading. + +## Why build the Tensor first + +Stage 1 bundles the three heaviest of the v0.1 hard constraints together: no heap allocation, no `std::vector`, compile-time fixed size. Once the Tensor is fixed, Stage 2's Dense is just multiply-adding two Tensors, and Stage 5's NumPy export is just filling numbers into an `inline constexpr std::array`. The Tensor's layout is the foundation of the later Python-vs-C++ diff; this line has to be nailed down in Stage 1, or Stage 5's golden test will never line up. + +## Three design decisions to make first + +### Decision one: at returns `std::expected`'s value, not a reference + +The inference hot path uses `operator()` (returns a reference, no check, out-of-bounds is UB — what we want is fast). But once in a while you'll access an element safely somewhere the bounds aren't certain — if it's out of range you need the error to come back, not UB. That "checked version" job goes to `at`. + +`at`'s return type is `std::expected` (C++23). Your first instinct might be: shouldn't it return a reference? Doesn't `std::array::at` return a reference? But here we hit a hard C++23 constraint: **`std::expected` doesn't accept T being a reference type.** `std::expected` fails a standard `static_assert(!is_reference_v)` and won't compile (locally, g++ 16.1 throws a chain of errors whose root cause is exactly this). + +So the "return a reference + go through expected errors" road is blocked at the standard level. Three compromises remain: return a pointer (`expected`), wrap in a `reference_wrapper`, or just return a value. The first two are either an ugly interface (an extra dereference) or an extra wrapper layer that's hard to explain to beginners. We pick returning a value: `at` gets a copy of the element, and out-of-range goes through expected's error path. + +The cost is that `at` can't modify the original element — `t.at(0,0).value() = 5.f` modifies the temporary copy inside the expected, the original stays put. But that's by design: `at`'s role in our Lab is "checked safe read"; to mutate an element, use `operator()`. The inference hot path is all `operator()`; `at` is only for tests and verification, where returning a value is plenty — and this cost isn't worth overturning the design for. + +### Decision two: fixed 2D, no variadic template + +What's good about putting dimensions in the type is covered in [intro 05](./05-shape-in-type.md); here we only discuss, once they're in the type, whether to do fixed 2D or variadic N-dimensional. The v0.1 MLP only ever has two shapes throughout: a row vector (input, output, bias) and a 2D matrix (weights). A fixed-2D `Tensor` covers them all; no need to reach for a variadic `Tensor`. That thing is steep to write, and the MLP has no use for 3D+ — paying complexity tax for a requirement that doesn't exist. 1D vectors use the alias `Vector = Tensor<1, Cols>` directly; Argmax and Dense inputs/outputs all go through it, semantics unified. + +### Decision three: `std::array` storage + row-major + +Storage isn't a choice — the hard constraint banned `std::vector`, leaving only `std::array`; the trial of the three candidates is in [intro 03](./03-why-not-built-in.md). The layout is **row-major** `internals_[i*Cols + j]`, matching NumPy's default C order, so that Stage 5's Python weights and the C++ Tensor align digit for digit, and Python's `W[i, j]` and C++'s `W(i, j)` point at the same number. The full derivation of row-major and the memory diagram are in [intro 04](./04-row-major.md); not repeated here. + +## Implementation guide + +### Interface sketch (aligned with the project code) + +The project has one header, `include/tinyml/tensor.hpp`; the signature looks like this (implementation in the project; here we cover the key points): + +```cpp +#pragma once +#include +#include +#include +#include + +namespace tamcpp::tinyml { + +template +class Tensor { + public: + // error codes hang inside the class: each dimension's Tensor carries its own copy — enough, no fuss + enum class Error { kShapeMismatch, kOutOfRange }; + + // checked safe read: out-of-range goes through expected's error, no exception thrown + constexpr std::expected + at(std::size_t i, std::size_t j) noexcept { + if (i >= Rows || j >= Cols) return std::unexpected{Error::kOutOfRange}; + return internals_[i * Cols + j]; + } + constexpr std::expected + at(std::size_t i, std::size_t j) const noexcept; // same, const version + + static_assert(Rows > 0 && Cols > 0, "dims must be positive"); + + constexpr Tensor() = default; + constexpr Tensor(std::array internals) + : internals_(std::move(internals)) {} + + constexpr std::size_t row() const noexcept { return Rows; } + constexpr std::size_t col() const noexcept { return Cols; } + constexpr std::size_t size() const noexcept { return internals_.size(); } + + // hot-path access: returns a reference, no check, out-of-bounds is UB + constexpr StorageType& operator()(std::size_t i, std::size_t j) noexcept; + constexpr const StorageType& operator()(std::size_t i, std::size_t j) const noexcept; + + // flat span view (Stage 2 Dense reads weights through this, no copy) + constexpr std::span view() const noexcept; + constexpr std::span view() noexcept; + + constexpr std::array& storage() noexcept; + constexpr std::array& storage() const noexcept; + + private: + std::array internals_{}; // this {} can't be omitted — see common pitfalls +}; + +template +using Vector = Tensor<1, Cols, StorageType>; + +} // namespace tamcpp::tinyml +``` + +A few spots easy to misread. + +The template parameter order is `` — **dimensions first, type defaulted**. So `Tensor<4, 3>` is `Tensor<4, 3, float>` — dimensions are the core of a Tensor's identity, they go first, and with type defaulting to float you save half the writing. + +`row()` / `col()` / `size()` with `constexpr` is enough (`static_assert(tensor.size() == 12)` passes); no need for `consteval`. `constexpr` allows runtime calls too — more lenient; if you really want compile-time-only, reach for `consteval` later. + +`at` is `noexcept` — it goes through expected's error path, throws no exception, matching the no-exceptions-on-core-path constraint. Note it returns a value, not a reference; see decision one. + +**Why the constructors aren't marked noexcept.** Functions like `at` and `operator()` are marked `noexcept`; the default constructor `Tensor() = default` and the constructor taking `std::array` aren't. The difference: access-only functions just do index comparison and element fetch (for float, copying doesn't throw) — they genuinely don't throw, so marking `noexcept` carries no risk; constructors actually have to construct the member `internals_{}`, and whether that throws depends on StorageType's own constructor. So the constructor's `noexcept` is tied to StorageType; we leave it to the compiler — `= default` automatically equips an implicit exception specification based on "whether constructing the member throws", and locally on g++ 16.1 `Tensor<4, 3, float>`'s default constructor is inferred as `noexcept(true)`. Not writing noexcept on the surface doesn't mean it throws; for float it just doesn't throw — the compiler simply owns that fact for you. The source line `// Q: why not noexcept?` is asking exactly that. + +### CMake: INTERFACE library + cross-compiler warning wrapper + +The top-level `CMakeLists.txt` declares the inference library as an `INTERFACE` (header-only; `Tensor` is entirely inline in the header, no `.cpp` product): + +```cmake +add_library(TAMCPP_TinyML INTERFACE include/tinyml/tensor.hpp) +target_include_directories(TAMCPP_TinyML INTERFACE ${CMAKE_CURRENT_SOURCE_DIR}/include) +``` + +Warning flags aren't portable across compilers (MSVC has one set, GCC/Clang another); wrap them in a function to reuse, instead of copying generator-expressions into every target: + +```cmake +function(tamcpp_target_warnings target) + target_compile_options(${target} PRIVATE + $<$:/W4;/permissive-;/Zi> + $<$>:-Wall;-Wextra;-Wpedantic;-g> + ) +endfunction() +``` + +Test targets get the same wrapper sugar; `tests/CMakeLists.txt` registers each test in one line: + +```cmake +function(tamcpp_add_test name source) + add_executable(${name} ${source}) + target_link_libraries(${name} PRIVATE Catch2::Catch2WithMain TAMCPP_TinyML) + tamcpp_target_warnings(${name}) + catch_discover_tests(${name}) +endfunction() + +tamcpp_add_test(smoke_catch2 smoke.cpp) +tamcpp_add_test(tensor_api tensor_api.cpp) # ← don't forget this one, see pitfall 5 +``` + +One more easily-forgotten line: the top level needs `enable_testing()`, otherwise every `add_test` registered by `catch_discover_tests` hangs in the air, and `ctest` forever reports "No tests found". + +## Verification + +The cases in `tests/tensor_api.cpp` are the criterion for Stage 1 having "really passed"; the row-major one in particular is a prerequisite for the Stage 5 diff: + +```cpp +TEST_CASE("dims are compile-time visible", "[tensor]") { + Tensor<4, 3> tensor; + static_assert(tensor.size() == 12); + static_assert(tensor.row() == 4); + static_assert(tensor.col() == 3); +} + +TEST_CASE("construct and access", "[tensor]") { + Tensor<2, 2> t(std::array{1.f, 2.f, 3.f, 4.f}); + REQUIRE(t(1, 0) == 3.f); +} + +TEST_CASE("row-major layout matches flat storage", "[tensor]") { + Tensor<2, 2> t(std::array{1.f, 2.f, 3.f, 4.f}); + for (std::size_t i = 0; i < 2; ++i) + for (std::size_t j = 0; j < 2; ++j) + REQUIRE(t(i, j) == t.storage()[i * 2 + j]); + REQUIRE(t.view().front() == t.storage()[0]); +} + +TEST_CASE("vector is a row tensor", "[tensor]") { + Vector<3> v; + static_assert(v.row() == 1 && v.col() == 3); +} + +TEST_CASE("out-of-range goes through expected", "[tensor]") { + Tensor<2, 2> t; + auto r = t.at(99, 99); + REQUIRE_FALSE(r); + REQUIRE(r.error() == Tensor<2, 2>::Error::kOutOfRange); + + // single-dimension out-of-range must be caught too (regression guard against &&: i out of range, j within) + auto r_single = t.at(99, 0); + REQUIRE_FALSE(r_single); + REQUIRE(r_single.error() == Tensor<2, 2>::Error::kOutOfRange); +} + +TEST_CASE("default construction is zero-initialized", "[tensor]") { + Tensor<2, 2> t; + REQUIRE(t(0, 0) == 0.f); +} +``` + +```bash +cmake -S . -B build && cmake --build build -j +ctest --test-dir build +``` + +All 6 cases green counts as passing. Pay special attention to the row-major one — it sets the alignment relationship with NumPy; if it fails, the Stage 5 diff has no foundation. + +## Common pitfalls + +1. **`at`'s bounds check uses `||`, not `&&`**: `if (i >= Rows && j >= Cols)` requires i, j to **both** be out of range before erroring; single-dimension overflow (`at(99, 0)`) slips right through and accesses `internals_[198]`, tripping `std::array`'s bounds assertion. Write `||` — i or j out of range returns the error. Verified locally with ASAN. +2. **The `{}` in `internals_{}` can't be omitted**: without `{}` on the member, `Tensor<2,2> t;`'s default construction leaves the `std::array` elements indeterminate, and `t(0, 0)` reads uninitialized garbage (UB). If the test passes, it's because the stack garbage happened to be 0 — pure luck. msan locally reports `use-of-uninitialized-value`. Adding `{}` value-initializes (float becomes 0.0f), and default construction means what it says. +3. **`std::expected` doesn't accept reference types**: `expected` won't compile (standard `static_assert(!is_reference_v)`). So `at` can't "return a reference + go through expected errors"; it has to return a value or a pointer. We pick value; see decision one. +4. **CTAD loses dimensions**: `Tensor t(std::array{...})` — this class template argument deduction drops `Rows` / `Cols` entirely; you must write `Tensor<2, 2>` explicitly. Don't count on CTAD to help. +5. **`tests/CMakeLists.txt` must register the test target**: write `tensor_api.cpp` but forget `tamcpp_add_test(tensor_api tensor_api.cpp)`, and the build won't compile it at all — the `ctest` you run only has smoke. Remember to register a line here when adding a new test file. diff --git a/documents/en/vol8-domains/ai/tiny_ml/stage1/index.md b/documents/en/vol8-domains/ai/tiny_ml/stage1/index.md new file mode 100644 index 000000000..f0f19b688 --- /dev/null +++ b/documents/en/vol8-domains/ai/tiny_ml/stage1/index.md @@ -0,0 +1,29 @@ +--- +title: "Stage 1 · Fixed-dimension Tensor" +description: "The data foundation of the inference engine. A concept intro (01-05) plus the implementation main doc (06-tensor.md)" +platform: host +tags: + - cpp-modern + - host + - intermediate +--- + +# Stage 1 · Fixed-dimension Tensor + +This stage builds the inference engine's data foundation: a compile-time fixed-dimension, row-major, `std::array`-backed `Tensor`. + +If the Tensor concept itself is new to you, read the five intro pieces (01-05) first — they walk from "what is a Tensor" all the way to "why it's designed this way". If you already know Tensors and just want the engineering, jump straight to [06-tensor.md](./06-tensor.md). + +## Intro: what a Tensor is, and why it's built this way + +1. [What is a Tensor — take the name off its pedestal](./01-what-is-tensor.md) — Demystify: it's just a fixed-size 2D table of numbers. +2. [What a Tensor holds in a neural network — four kinds of data, one container](./02-tensor-in-neural-network.md) — Input / weights / bias / output — all Tensors. +3. [Why not use what's already there — three suspects on trial](./03-why-not-built-in.md) — Where `vector`, native arrays, and nested `array` each die. +4. [Row-major — how a 2D coordinate lands in 1D memory](./04-row-major.md) — `i*Cols + j`, aligned with NumPy. +5. [Shape baked into the type — why dimensions are template parameters](./05-shape-in-type.md) — The type system as a free shape-checker. + +## Implementation: writing the Tensor + +- [The fixed-dimension Tensor — the inference engine's data foundation](./06-tensor.md) — Full interface sketch, `std::expected` error handling, CMake, verification tests, common pitfalls. + +Once the five intro pieces are behind you, the design trade-offs in 06-tensor.md won't feel like they're hanging in mid-air. diff --git a/documents/vol8-domains/ai/index.md b/documents/vol8-domains/ai/index.md new file mode 100644 index 000000000..f8e8c26c9 --- /dev/null +++ b/documents/vol8-domains/ai/index.md @@ -0,0 +1,17 @@ +--- +title: "AI 与 TinyML" +description: "用现代 C++ 从零手搓玩具级神经网络推理器,理解 TinyML 推理在 MCU 上到底怎么跑" +platform: host +tags: + - cpp-modern + - host + - intermediate +--- + +# AI 与 TinyML + +这个子域收录 TAMCPP 的 AI / TinyML 方向工程 Lab。和 [嵌入式开发](../embedded/)、[网络编程](../networking/) 一样,这里不放概念速查,只放"从零造一个能跑的东西"的动手项目——目标是用现代 C++ 把神经网络推理这件事表达得清晰、可控、可解释,顺便把 `std::array` / `std::span` / `constexpr` / 模板维度约束 / 无异常错误处理 / 无堆分配 / 静态权重这些点焊在一起。 + +## 项目 + +- [TinyInferCpp-Lab](./tiny_ml/) —— 用 C++23 从零手搓一个玩具级神经网络推理器(Input → Dense → ReLU → Dense → Argmax),float32、固定结构、核心路径无堆分配无异常无 RTTI。PC 闭环优先,STM32 部署作为后续环节。 diff --git a/documents/vol8-domains/ai/tiny_ml/index.md b/documents/vol8-domains/ai/tiny_ml/index.md new file mode 100644 index 000000000..4e0f8ffc9 --- /dev/null +++ b/documents/vol8-domains/ai/tiny_ml/index.md @@ -0,0 +1,22 @@ +--- +title: "TinyInferCpp-Lab" +description: "用 C++23 从零手搓一个玩具级神经网络推理器的动手 Lab" +platform: host +tags: + - cpp-modern + - host + - intermediate +--- + +# TinyInferCpp-Lab + +用 C++23 从零手搓一个玩具级神经网络推理器(Input → Dense → ReLU → Dense → Argmax),float32、固定结构、核心路径无堆分配无异常无 RTTI。教学目的,不替代 TFLite Micro / STM32Cube.AI / CMSIS-NN / TinyMaix / NNoM / emlearn。 + +## 文章 + +- [Stage 0 · 工程脚手架](./stage0/scaffold.md) —— standalone CMake23 工程 + Catch2 冒烟,把工具链地基浇好 +- [Stage 1 · 固定维度 Tensor](./stage1/06-tensor.md) —— 编译期固定维度、行主序、`std::array` 存储的 Tensor + `std::expected` + +## 后续(进行中) + +Stage 2(Dense 与权重布局)→ Stage 3(ReLU / Argmax)→ Stage 4(DemoModel 串联)→ Stage 5(NumPy 训练导出)→ Stage 6(Python/C++ golden test 对拍)→ Stage 7(嵌入式友好审查)→ Stage 8(MCU porting 规划)。配套工程在 `code/volumn_codes/vol8-labs/ai/tiny_ml/`。 diff --git a/documents/vol8-domains/ai/tiny_ml/stage0/scaffold.md b/documents/vol8-domains/ai/tiny_ml/stage0/scaffold.md new file mode 100644 index 000000000..e9f05afd6 --- /dev/null +++ b/documents/vol8-domains/ai/tiny_ml/stage0/scaffold.md @@ -0,0 +1,148 @@ +--- +title: "工程脚手架——把工具链地基浇好" +description: "搭一个 standalone CMake23 工程,FetchContent 拉 Catch2,跑通冒烟测试。Stage 0 一行业务推理代码都不写,只确认工具链+构建+测试这条地基是通的" +chapter: 8 +order: 6 +platform: host +difficulty: intermediate +cpp_standard: [23] +reading_time_minutes: 8 +prerequisites: + - "CMake 基础" +tags: + - host + - cpp-modern + - intermediate + - CMake + - 工具链 + - 基础 +--- + +# 工程脚手架——把工具链地基浇好 + +工程工程,第一个事情是把架子搭建好,不着急立马上手干活。TinyInferCpp-Lab 的 Stage 0 只干一件事:把 standalone 的 CMake 工程搭起来,有一个能编译能跑的 Catch2 冒烟测试,`.gitignore` 忽略好构建产物。一行业务推理代码都不写,这一 stage 只确认工具链、构建系统、测试框架这条地基通,后面每次写完代码 `cmake --build` 就能拿到反馈。配套工程在 `code/volumn_codes/vol8-labs/ai/tiny_ml/stage0/`。 + +## 为什么不是直接写推理代码 + +第一周卡在"编译不过 / Catch2 拉不下来 / clangd 不工作"上,比某个算法写不出来更容易让人半途而废。Stage 0 把这些前置解决掉,还顺手强制你现在就确认工具链对 C++23 的支持,别等到 Stage 5 才发现编译器版本不够,那时候回退成本高得多。 + +## 工程长什么样 + +standalone CMake 工程,目录就这几样: + +```text +stage0/ +├── CMakeLists.txt # standalone,FetchContent Catch2 v3.5.0 +├── .gitignore # build/ + .cache/ +├── tests/smoke.cpp # 工具链冒烟 +└── logs/ # 踩坑账本(实证,见后面常见坑) +``` + +## CMake 骨架 + +实际的 `CMakeLists.txt` 长这样,顺着功能顺序拆: + +```cmake +cmake_minimum_required(VERSION 3.20) +project(tamcpp_mlinfra LANGUAGES CXX) + +set(CMAKE_CXX_STANDARD 23) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_EXPORT_COMPILE_COMMANDS ON) +``` + +头三行锁标准。`CMAKE_CXX_STANDARD 23` 配 `STANDARD_REQUIRED ON`,把 C++23 钉死,不让编译器偷偷降级。光写 `STANDARD` 不加 `STANDARD_REQUIRED`,某些编译器会静悄悄降到它能支持的最高标准,你后面用到 C++23 特性时炸出一个莫名其妙的报错,排都排不到标准头上。`CMAKE_EXPORT_COMPILE_COMMANDS ON` 是给 clangd 生成 `compile_commands.json`,IDE 的跳转、补全、诊断全指望它,不开这句,写代码会很难受。 + +```cmake +include(FetchContent) + +FetchContent_Declare( + Catch2 + GIT_REPOSITORY https://github.com/catchorg/Catch2.git + GIT_TAG v3.5.0 +) + +FetchContent_MakeAvailable(Catch2) +list(APPEND CMAKE_MODULE_PATH ${catch2_SOURCE_DIR}/extras) +``` + +这段把 Catch2 在配置期从 Git 拉下来,直接 `add_subdirectory` 同构编译进工程,不靠预装或 submodule,版本被 `GIT_TAG v3.5.0` 锁死。 + +三步各司其职。`FetchContent_Declare` 只登记 name、来源、tag,不触发下载。`FetchContent_MakeAvailable` 才在首次配置时 clone、`add_subdirectory`、定义出 `Catch2::Catch2WithMain` 这个 target,顺带设一个 `_SOURCE_DIR` 变量供你引用,变量名规则是 depname 全小写,所以这里是 `catch2_SOURCE_DIR`。最后那行 `list(APPEND CMAKE_MODULE_PATH .../extras)` 把 Catch2 自带的辅助 `.cmake`(`Catch.cmake` 等,里面含 `catch_discover_tests`)挂进 CMake 的模块搜索路径,后面想 `include(Catch)` 才找得到。 + +下载的源码落在 `build/_deps/catch2-src/`,中间产物在 `-build/`,二次配置不重拉,源码树干干净净。这套机制的实证,包括拉不下来时怎么救急,记在 `logs/002-fetchcontent-catch2.md`。 + +```cmake +add_executable(smoke_catch2 tests/smoke.cpp) +target_link_libraries(smoke_catch2 PRIVATE Catch2::Catch2WithMain) + +target_compile_options(smoke_catch2 PRIVATE + $<$:/W4;/permissive-;/Zi> + $<$>:-Wall;-Wextra;-Wpedantic;-g> +) +``` + +`target_compile_options` 是现代 CMake 的 target-based 写法,选项挂在指定 target 上,不污染所有目标,不像老式那种全局 `add_compile_options()`。`PRIVATE` 意思是只编译自己、不往下传。警告标志几乎一律 PRIVATE,下游才不管你怎么编译它,而且警告标志编译器强相关,一旦 PUBLIC 传下去,换个编译器立刻炸。 + +最后两行生成器表达式要拆开讲。结论先放这:`-Wall -Wextra -Wpedantic -g` 全是 GCC/Clang 的私有方言,MSVC 一个都不认。MSVC 有自己的一套,警告级用 `/W4`(实用最高档,`/Wall` 会刷屏没法用),严格标准符合用 `/permissive-`(禁掉非标准扩展),调试信息用 `/Zi`(写进 PDB)。所以工程里按编译器 ID 分挂,MSVC 走一条,非 MSVC 走另一条。 + +第二条特意写成 `$>` 而不是枚举 `$`。前者意思是只要不是 MSVC 就走 GCC/Clang 那套,自动覆盖 Intel、LLVM 这些同样认 `-Wall` 的编译器,不用每加一个编译器去改清单。完整的标志对照在 `logs/003-target-compile-options.md`。 + +## 为什么是 C++23 而不是 C++20 + +Stage 0 本身不依赖任何 C++23 特性,你拿 C++20 也能把这 stage 跑通。但后面 Stage 1 起要用 `consteval`、更完整的 `constexpr`、`std::expected`,标准现在就设成 23,省得以后回头改。 + +## 冒烟测试:挑个狠一点的探针 + +```cpp +#include +#include + +TEST_CASE("Smoke up the labs") { + std::print("Our smoke Test"); +} +``` + +冒烟测试的用处就是证明链路通了,挑个狠一点的探针更划算。这里特意用 `std::print`,它要 C++23 的 `` 头可达,一句话同时压上工具链、Catch2、CMake、C++23 标准库四件事,比老老实实写一句 `REQUIRE(1 + 1 == 2)` 探得深。 + +## 验证 + +```bash +cmake -S . -B build # 首次 FetchContent 拉 Catch2,需联网 +cmake --build build -j +./build/smoke_catch2 +``` + +第 3 步会打印 `Our smoke Test` 并报 `All tests passed`。再人工确认一下,IDE 里 clangd 能跳进 `smoke.cpp`、能补全 Catch2 的宏,说明 `compile_commands.json` 真生效了。 + +## 常见坑 + +::: warning FetchContent 拉不下来 Catch2 +WSL 访问 github 不稳,失败日志一般是 `Failed to connect to github.com port 443`。救急办法是手动浅克隆一份,用预放置目录替换自动下载: + +```bash +git clone --depth 1 -b v3.5.0 https://github.com/catchorg/Catch2.git /tmp/catch2 +cmake -S . -B build -DFETCHCONTENT_SOURCE_DIR_CATCH2=/tmp/catch2 +``` + +变量名规则是 `FETCHCONTENT_SOURCE_DIR_<大写DEPNAME>`,本质是替换 `GIT_REPOSITORY` 的下载源。实证见 `logs/002`。 +::: + +::: warning 编译器不支持 C++23 +`set(CMAKE_CXX_STANDARD 23)` 要 GCC 13+ / Clang 16+ / MSVC VS2022 17.6+。开工前先自测一下: + +```bash +g++ --version +echo 'int main(){return 0;}' | g++ -std=c++23 -x c++ - -o /tmp/cxx23_smoke && echo "C++23 OK" +``` + +本机实测 g++ 16.1.1、clang++ 22.1.6(见 `logs/003`)。报错就升级编译器,别降标准,降了后面 Stage 1 起会顶不住。 +::: + +::: warning clangd 报"找不到头文件" +九成是 `compile_commands.json` 没生成,或者 clangd 没指向 `build/`。确认 `CMAKE_EXPORT_COMPILE_COMMANDS ON` 已经配上。`.cache/clangd` 是 clangd 建索引用的,别误删也别提交,已经 `.gitignore` 了。 +::: + +::: warning 在仓库根目录跑 cmake +别在仓库根跑 cmake。这个工程就窝在自己目录 `stage0/` 里构建,`build/` 产物留在本目录,而且已经被 `.gitignore` 忽略。 +::: diff --git a/documents/vol8-domains/ai/tiny_ml/stage1/01-what-is-tensor.md b/documents/vol8-domains/ai/tiny_ml/stage1/01-what-is-tensor.md new file mode 100644 index 000000000..2c0995fab --- /dev/null +++ b/documents/vol8-domains/ai/tiny_ml/stage1/01-what-is-tensor.md @@ -0,0 +1,46 @@ +--- +title: "Tensor 是什么——先把名字从神坛上拿下来" +description: "祛魅:在咱们 Lab 里,Tensor 就是一张固定大小的二维数表,存 float,底层是连续的 std::array,外面套一层二维访问的壳,不神秘" +chapter: 8 +order: 7 +platform: host +difficulty: beginner +cpp_standard: [23] +reading_time_minutes: 4 +related: + - "固定维度 Tensor——推理器的数据底座" + - "Tensor 在神经网络里装什么" +tags: + - host + - cpp-modern + - beginner + - 基础 +--- + +# Tensor 是什么——先把名字从神坛上拿下来 + +哦,你说Tensor啊,笔者查了一圈,也许我们可以简单的说——N维数组。 + +停停停!什么N维数组?别逗你Tensor笑了。 + +好吧,数学里确实有"张量",而且有一套吓人的递进,请允许笔者稍微翻一下我的线性代数教材:零维叫标量(一个数),一维叫向量(一列数),二维叫矩阵(一张数表),三维往上才正式叫张量。所以严格讲,二维的东西该叫矩阵,不该叫张量。 + +但笔者发现,深度学习社区似乎没这么讲究。PyTorch、TensorFlow 把"多维数组"统一叫 Tensor,不管你几维。这个叫法流行开之后,Tensor 就成了"神经网络里装数字的那个容器"的代名词。咱们 Lab 沿用它,纯粹因为这是最通行的行业词汇,你以后看别的资料、读别的框架能对上号。 + +## 在咱们 Lab 里,它到底是什么 + +把那些光环全扒掉,Tensor 就是一张**固定大小的二维数表**,每一格存一个 float。 + +啥,你说这个有点费劲,嘶,也许你需要到前面的基础(我说的就是我们项目的卷一)补一补。我倒是觉得它可以被理解为一张 Excel 表:Rows 行、Cols 列,每格一个浮点数。想看第 2 行第 3 列,就定位到那格。就这么个东西。如果 Rows=1,它退化成一行,那就是个向量,后面会看到,输入、偏置、输出都是这种"一行"的形态。 + +剥到底层,它就是一段连续的 float 数组,长度 `Rows * Cols`,在内存里老老实实排成一条线。外面套的那层 Tensor,本质是个壳:你写 `t(i, j)` 要访问第 i 行第 j 列,它帮你换算成"这条线上第几个数",再帮你取出来。换算规则(行主序)是 [04 篇](./04-row-major.md)的事,先不展开。这里你只要先接受一个画面:**二维的逻辑坐标,底下是一维的连续存储,中间靠一个换算公式连起来**。 + +所以"造一个 Tensor",说白了就是造这个壳:一段固定大小的连续存储,加上一套二维访问的语法。固定大小的连续存储,C++ 这边天然对应 `std::array`,剩下的活就是给 `operator()(i, j)` 套上换算。Stage 1 后半段干的就是这个。 + +## 那为什么不直接用现成的二维数组 + +这个时候,熟悉C和C++的朋友开怀大笑:哈?逗我笑呢,咱们本来就有二维数组 `float[4][3]`, 你说他不够modern,咱们还有第二关: `std::array, 4>`,直接拿来用不行吗,非得自己造一个 Tensor? 我说你就是爱造轮子。 + +Stop,笔者的答案是:能用,但用着别扭,而且和咱们后面要守的几条硬约束(无堆分配、和 NumPy 对拍、形状编译期可见)对不齐。具体哪里别扭、怎么个对不齐法,是 [03 篇](./03-why-not-built-in.md)专门要拆的。 + +这篇你只要带着一个画面离开:**Tensor 就是一张固定大小的二维数表,底下是连续的 float 数组,外面套层二维访问的壳**。带着它,[下一篇](./02-tensor-in-neural-network.md)我们看 Tensor 在神经网络里到底装什么,它那几个格子里,分别放的是些什么数。 diff --git a/documents/vol8-domains/ai/tiny_ml/stage1/02-tensor-in-neural-network.md b/documents/vol8-domains/ai/tiny_ml/stage1/02-tensor-in-neural-network.md new file mode 100644 index 000000000..093a6a371 --- /dev/null +++ b/documents/vol8-domains/ai/tiny_ml/stage1/02-tensor-in-neural-network.md @@ -0,0 +1,90 @@ +--- +title: "Tensor 在神经网络里装什么——四种数据,一种容器" +description: "拆一个 Dense 层,看输入、权重、偏置、输出四种 Tensor 各自什么形状;神经网络里流动的数字全是 Tensor,向量不过是 Rows=1 的 Tensor" +chapter: 8 +order: 8 +platform: host +difficulty: beginner +cpp_standard: [23] +reading_time_minutes: 6 +prerequisites: + - "Tensor 是什么——先把名字从神坛上拿下来" +related: + - "固定维度 Tensor——推理器的数据底座" +tags: + - host + - cpp-modern + - beginner + - 基础 +--- + +# Tensor 在神经网络里装什么——四种数据,一种容器 + +上一篇把 Tensor 祛魅成"一张二维数表"。那这张表在咱们这套神经网络里到底装什么? + +答案可能会让你松口气:**几乎装一切**。神经网络里流动的数字,从头到尾,全装在 Tensor 里。输入是 Tensor,训练好的参数是 Tensor,每一步算出来的中间结果也是 Tensor。所谓"神经网络推理",**剥到最底下,就是一段 Tensor 进、Tensor 出的计算。** + +先把咱们自己这个 Lab 的场景摆出来,不然太抽象。我们要造的是一个看传感器读数判断设备状态的小机器:喂进去三个读数,温度、湿度、光照,它吐出一个判断,正常、警告、还是危险。就这么个具体的东西。这个小机器从头到尾流动的数字全是 Tensor,这一篇就来拆它身边到底有哪几种。 + +为了把这个画面立住,我们拆一个 Dense 层(全连接层,我知道您可能又懵了,假装它是一个神秘的小词语,一个函数“A”一样的东西)看看它身边有哪几种 Tensor。Dense 是咱们这个 MLP 里唯一的算术层,后面的 Stage 2 整篇都在写它,这里先不碰它怎么算,只看它手边摆着哪些数。 + +手边到底摆着哪些数?函数 A 中间怎么变的不用细究,但它要能干活,内部一定存着点东西,否则同样的输入凭什么每次都吐出同样的输出。存的主要是两样。 + +一样规定每个输出要拿几个输入、各按多少比例掺在一起再加起来,这堆比例就是**权重**,你可以想成一张配方表;另一样是每个输出算完之后还要单独补的常数,叫**偏置**,像调音台最后再推一下推子。 + +## 一个 Dense 层的四种 Tensor + +具体到第一层,`Dense(3, 4)` 的意思就是把 3 个输入揉成 4 个中间值。围绕这一层,有四种 Tensor: + +| 数据 | 是什么 | 形状 | 咱们的写法 | +|---|---|---|---| +| 输入 x | 三个传感器读数 | 长度 3 的向量 | `Tensor<1, 3>` | +| 权重 W | 把 3 个输入揉成 4 个输出的系数 | 4 行 × 3 列 | `Tensor<4, 3>` | +| 偏置 b | 每个输出额外加的常数 | 长度 4 的向量 | `Tensor<1, 4>` | +| 输出 y | 这层算出来的 4 个中间值 | 长度 4 的向量 | `Tensor<1, 4>` | + +术语糊一脸了,慢慢说。 + +**输入**就是喂进来的三个读数,一个温度、一个湿度、一个光照。它天然是"一行 3 个数",所以是 `Tensor<1, 3>`,行数 1、列数 3。神经网络里把这种"一行"的形态专门叫向量,咱们用 `Vector` 这个别名来表达,本质还是 `Tensor<1, N>`。 + +**权重**是这一层真正"学到"的东西,也是整个神经网络最核心的参数。它是一张 4×3 的表:4 行对应 4 个输出,每行 3 个数对应 3 个输入。第 i 行那 3 个数,就是用来算第 i 个输出的系数。至于这几个系数怎么跟输入乘起来再加好,是 Stage 2 的活,这里只要先认下"它是一张 4×3 的表,行对输出、列对输入"。 + +**偏置**是每个输出额外加的一个常数,共 4 个输出,所以是长度 4 的向量。它的作用是给每个输出做一次独立的平移,纯粹加法,不牵扯输入。权重决定"输入怎么混",偏置决定"混完之后整体抬多少"。 + +**输出**就是这一层算完的 4 个中间值,形态跟偏置一样,也是长度 4 的向量。这 4 个数会喂给下一层(先过一个 ReLU 把负数归零,再进第二层 Dense),一路往后传。 + +四种 Tensor 讲完了。你发现没有,**向量和矩阵全是用同一个 `Tensor` 表达的**,向量不过是 Rows=1 的特殊情况。这就是咱们设计 Tensor 时刻意做的统一:一套容器,装下神经网络里所有形状的数据,不用为向量和矩阵分别造两套。 + +## 整条流水线,全是 Tensor + +把视角拉到整条流水线,这个"统一容器"的直觉会更清楚。咱们这个 MLP 从输入到输出: + +```mermaid +flowchart LR + A(["输入 Tensor 1×3"]) --> B["Dense"] + B --> C(["Tensor 1×4"]) + C --> D["ReLU"] + D --> E(["Tensor 1×4"]) + E --> F["Dense"] + F --> G(["Tensor 1×3"]) + G --> H["Argmax"] + H --> I(["类别
正常/警告/危险"]) +``` + +每一级的数据都是 Tensor。第一层 Dense 吃一个 1×3 的 Tensor、吐一个 1×4 的;ReLU 把 Tensor 里每个负数变成 0,形状不变;第二层 Dense 再把 1×4 变成 1×3;最后 Argmax 在这 3 个数里挑最大的,它的位置(0/1/2)就是分类结果,正常、警告、危险。 + +所以后面每个 Stage 在干嘛?都是在拿 Tensor 做点什么: + +- Stage 2 的 Dense,是拿输入 Tensor 和权重 Tensor 做乘加,加上偏置 Tensor,产出输出 Tensor +- Stage 3 的 ReLU,是把一个 Tensor 里的负数全归零 +- Stage 3 的 Argmax,是在一个 Tensor 里找最大的那个数的位置 + +Tensor 一旦定下来,后面这些 stage 就都是在它之上的运算。这也是为什么咱们花一整个 Stage 1 把 Tensor 造扎实:它一塌,后面全得跟着重写。 + +## 那为什么不用现成的容器装这些 + +你又该问了:既然就是装几个浮点数,用 `std::vector` 装输入、用 `std::vector>` 装权重,行不行? + +这个问题的答案,跟上一篇结尾问的"为什么不用现成二维数组"其实是同一个。它牵着咱们 v0.1 要守的那几条硬约束,值得单独拆。[03 篇](./03-why-not-built-in.md)就把 `std::vector`、嵌套 `std::array`、原生二维数组这几个候选摆开来逐个过,讲清楚为什么最后非得自己造这个 Tensor。 + +这篇你带走两样东西:**神经网络里流动的输入、权重、偏置、输出全是 Tensor,向量不过是 Rows=1 的 Tensor**;以及**后面所有 stage 都是 Tensor 之上的运算**。有了这个底,再去碰 Tensor 的设计取舍,就知道每个取舍是在为什么服务了。 diff --git a/documents/vol8-domains/ai/tiny_ml/stage1/03-why-not-built-in.md b/documents/vol8-domains/ai/tiny_ml/stage1/03-why-not-built-in.md new file mode 100644 index 000000000..5d318fbf9 --- /dev/null +++ b/documents/vol8-domains/ai/tiny_ml/stage1/03-why-not-built-in.md @@ -0,0 +1,80 @@ +--- +title: "为什么不用现成的——三个候选过堂" +description: "把 std::vector、原生二维数组、嵌套 std::array 三个候选摆上被告席,逐个看死在哪条 v0.1 硬约束上,逼出扁平 array + 下标映射 + 维度进模板的最小方案" +chapter: 8 +order: 9 +platform: host +difficulty: intermediate +cpp_standard: [23] +reading_time_minutes: 7 +prerequisites: + - "Tensor 在神经网络里装什么——四种数据,一种容器" +related: + - "固定维度 Tensor——推理器的数据底座" + - "行主序——二维坐标怎么落进一维内存" + - "形状塞进类型——维度为什么是模板参数" +tags: + - host + - cpp-modern + - intermediate + - 内存管理 + - 基础 +--- + +# 为什么不用现成的——三个候选过堂 + +前两篇一直在说"咱们要自己造一个 Tensor",但都没正面回答那个最扎心的问题:**C++ 明明已经有一堆装数字的容器了,为什么不用,非得自己造?** + +这篇就把这个问题正面拆开。我们把三个最现成的候选摆上被告席,逐个过堂,看每个都死在哪条硬约束上。走完一遍你就会明白,咱们这个 Tensor 不是凭空发明的,是从这些"不行"里被逼出来的。 + +先回忆一下它要满足什么(v0.1 的硬约束): + +- 核心推理路径不堆分配(不 new / malloc) +- Tensor 不用 `std::vector` +- 大小编译期固定 +- 权重要能 `inline constexpr std::array` 摆着 +- 还得跟 NumPy 的内存布局对得上,方便后面 Stage 6 对拍 + +带着这份清单,开始过堂。 + +## 候选一:`std::vector`(包括嵌套) + +笔者相信大家的第一反应是这个,对吧,最直接的选择。`std::vector` 装输入,`std::vector>` 装权重矩阵,动态大小,用着舒服。 + +死在哪:第一,`std::vector` 内部是动态分配,元素存堆上,构造时调 `new`(通常通过 allocator),这直接撞硬约束"核心路径不堆分配"。MCU 上每来一次推理就申请释放一堆小内存,碎片化、延迟不可控,这是嵌入式最忌讳的事。第二,它的大小是运行期的,`v.size()` 编译期不知道,满足不了"编译期固定大小"这条。第三,如果用嵌套 `vector>` 装矩阵,更糟:每一行是一个独立的 vector,各自有自己的堆块,内存不连续,访问时指针满世界跳,cache 命中稀烂,而且 N 行就是 N 次堆分配。 + +所以 Lab 直接在硬约束里把 `std::vector` 拉黑了:"Tensor 不用 `std::vector`"。不是它不好用,是它在咱们这条赛道上根本不该出现。 + +## 候选二:原生二维数组 `float[Rows][Cols]` + +```cpp +float weight[4][3]; +``` + +这个不堆分配(栈上或静态区),大小编译期固定,看起来满足了两条硬约束。 + +死在哪:C 风格数组是个半成品。第一,它往函数里一传就衰变成指针,维度信息当场丢了,`f(float w[4][3])` 里你拿不到那个 4 和 3,只剩下裸指针,所有边界检查全得手写。第二,它没有任何 API,想拿行列数得自己 `sizeof(weight)/sizeof(weight[0])` 这么绕,写一次烦一次。第三,我们后面要的越界检查(`at()` 返回 `std::expected`)、扁平视图(`flat()` 返回 `std::span`)、跟 NumPy 对拍时的连续内存保证,它一个都不带,全得在外面包一层。 + +包一层是可以,但既然要包一层壳,底下用什么存就值得重新想了。这就引出第三个候选。 + +## 候选三:嵌套 `std::array, Rows>` + +```cpp +std::array, 4> weight; +``` + +这是 C++ 里"固定大小二维数组"最正经的现成写法。`std::array` 不堆分配、大小编译期固定、内存连续(嵌套 array 的整体内存也是连续的,因为内层 array 是 POD)。它补上了候选二漏掉的"维度衰变"问题,因为 `std::array` 是个正经类型,传值、传引用都带着维度走。 + +死在哪:它能用,是三个候选里最接近的,但用着别扭。第一,访问是二级的 `weight[i][j]`,不是咱们想要的 `weight(i, j)` 统一接口,也和 NumPy 的 `W[i, j]` 风格对不上。第二,形状要从类型里抠:`std::array, 4>` 这个类型,外层是 4(行)、内层是 3(列),倒着读,容易搞反,模板元里抠出来也啰嗦。第三也是最要命的,我们后面要的扁平连续视图(`std::span`)、跟 NumPy `flatten()` 对拍、`at()` 越界检查,嵌套 array 一个都不直接给,全得自己手动展平、手动包。 + +算一下账:与其用嵌套 array,再花一圈功夫给它套 flat 视图、统一 `(i, j)` 接口、行主序保证、错误处理,这些壳子的活加起来,跟从零造一个 Tensor 已经差不多了。而且嵌套 array 在"扁平连续"这件事上绕了一道弯:数据本质是一维的,它硬给你摆成二维嵌套,你再展平回来。 + +## 三个都不行,逼出一个方案 + +把三个候选的结局摆一起,能看出一个共同点:**我们需要的东西,本质是一段固定大小的连续 float 存储,加一套好用的二维访问**。三个候选要么存储不对(vector 在堆、且大小动态),要么访问层太糙(原生数组、嵌套 array 都不给你 `(i, j)`、`flat()`、`at()` 这些)。 + +那就直接奔本质去。存储用 `std::array`,一段编译期固定大小的连续内存,不堆分配,天然对得上 `inline constexpr std::array` 权重。访问在外面包一层,`operator()(i, j)` 负责把二维坐标换算成一维下标,需要检查的场合另开一个 `at()` 走 `std::expected`。维度 Rows、Cols 直接进模板参数,塞进类型里,编译期可见,顺便让类型系统帮你抓形状错。 + +这三块零件,各自由后面几篇接手:二维坐标怎么换算成一维下标(行主序)是 [04 篇](./04-row-major.md);维度进模板参数有什么好处是 [05 篇](./05-shape-in-type.md);完整接口长什么样、怎么动手写是 [06-tensor.md](./06-tensor.md)。 + +到这里,Tensor 这个设计从哪来的就清楚了:不是谁拍脑袋发明的,是把三个现成方案的缺点都踩一遍之后,被逼出来的最小形态。 diff --git a/documents/vol8-domains/ai/tiny_ml/stage1/04-row-major.md b/documents/vol8-domains/ai/tiny_ml/stage1/04-row-major.md new file mode 100644 index 000000000..0d86637d4 --- /dev/null +++ b/documents/vol8-domains/ai/tiny_ml/stage1/04-row-major.md @@ -0,0 +1,80 @@ +--- +title: "行主序——二维坐标怎么落进一维内存" +description: "拆 operator()(i,j) 背后的换算:二维坐标落进一维内存是 i*Cols+j,一行存满再下一行;这个顺序跟 NumPy 默认 C order、C++ 原生数组都一致,是 Stage 6 对拍的基础" +chapter: 8 +order: 10 +platform: host +difficulty: intermediate +cpp_standard: [23] +reading_time_minutes: 5 +prerequisites: + - "为什么不用现成的——三个候选过堂" +related: + - "固定维度 Tensor——推理器的数据底座" + - "形状塞进类型——维度为什么是模板参数" +tags: + - host + - cpp-modern + - intermediate + - 内存管理 +--- + +# 行主序——二维坐标怎么落进一维内存 + +[上一篇](./03-why-not-built-in.md)定了方向:存储用一段扁平的 `std::array`,外面套一层 `operator()(i, j)` 做坐标换算。这篇就拆那个换算:**给你一个二维坐标 (i, j),怎么算出它在这段一维内存里的位置?** + +你可能会想,这不就是个下标计算嘛,能有多难。难是不难,但它有个名字你得先认识:行主序。这名字听着唬人,说到底就是个“按什么顺序摆”的约定。可这个约定不搞清楚,后面 NumPy 对拍分分钟翻车。 + +## 一行一行抄 + +先把问题摆具体。假设有个 2 行 3 列的矩阵,逻辑上你看到的是一张二维表: + +```text + col0 col1 col2 +row0 [ a00 a01 a02 ] +row1 [ a10 a11 a12 ] +``` + +但内存是一维的,只有一条线,不认什么行什么列。这张表怎么塞进一条线里? + +最自然的办法,也是你手抄一张表时会干的:**先把第一行从头抄到尾,再抄第二行**。于是内存里就成了这样: + +```text +下标: 0 1 2 3 4 5 + [ a00, a01, a02, a10, a11, a12 ] + └──── 第0行 ────┘ └──── 第1行 ────┘ +``` + +这就是行主序(row-major),说人话就是“以行为主”:一行存完,再存下一行。`a00` 落在下标 0,`a02` 落在下标 2,`a10` 紧接着 `a02` 占下标 3,因为它在第二行最开头,得等第一行那三个数都躺好了才轮到它。 + +## 公式:i * Cols + j + +画面有了,现在写换算。给你坐标 (i, j),算它前面已经占了多少格。 + +第 i 行前面,已经塞满了 i 整行,每行 Cols 格,所以前面占了 `i * Cols` 格。进了第 i 行之后,还得再往右走 j 格才到第 j 列。两头一加: + +```text +flat_index = i * Cols + j +``` + +就这一行。`operator()(i, j)` 的全部秘密就在这:`return data_[i * Cols + j];`。 + +拿咱们 Lab 的 `Tensor<4, 3>` 验一下,Rows=4、Cols=3。访问 `t(2, 1)`,也就是第 2 行第 1 列:前面压着 2 整行,每行 3 格,共 6 格;再在第 2 行里往右走 1 格,加 1,得下标 7。所以 `t(2, 1)` 就是 `data_[7]`。对着上面那张图数一数,下标 7 那格确实落在第 2 行第 1 列(下标从 0 数),对得上。 + +## 凭什么是行主序,不是列主序 + +存法不止一种。你也可以一列一列抄,先存满第 0 列所有行,再存第 1 列,那叫列主序(column-major),公式变成 `j * Rows + i`。Fortran、MATLAB,还有一部分 BLAS 实现就是列主序的,搞科学计算的老前辈们用得多。 + +那咱们为什么偏要选行主序?两条理由,都很硬。 + +第一条,C 和 C++ 的原生二维数组本来就是行主序。`float w[4][3]` 在内存里就是 `w[0][0], w[0][1], w[0][2], w[1][0], ...`,一行接一行。C++ 程序员对这套顺序最熟,不用做任何心智转置。 + +第二条,也是更要紧的,NumPy 默认的 C order 就是行主序。`np.array([[1,2,3],[4,5,6]])` 调一下 `.flatten()`,出来的就是 `[1,2,3,4,5,6]`,跟咱们 `data_[i*Cols+j]` 的顺序一模一样。这意味着 Stage 5 从 NumPy 导出权重时,Python 那边 `W[i, j]` 和 C++ 这边 `t(i, j)` 指向的是同一个数,Stage 6 的对拍才能一位一位地比。 + +要是哪边偷偷用了另一套序,对拍就全错,你拿 C++ 行主序的 `data_[7]` 去比 NumPy 列主序某个位置的数,怎么都对不上,能 debug 到怀疑人生。所以这条布局,笔者在这里就把它定下来,后面所有 stage 都不许再改主意。 + +## 带走什么 + +行主序就这点东西,别看它简单,它是整个 Lab 的对齐基础,Python 那边和 C++ 这边能不能对上,全指望它。记住两样就够:二维坐标 (i, j) 在一维内存里的位置是 `i * Cols + j`,先存满一行再下一行;这个顺序跟 NumPy 默认一致,跟 C++ 原生数组也一致。 + +[下一篇](./05-shape-in-type.md)讲维度:Rows 和 Cols 这两个数,为什么要进模板参数、塞进类型里,而不是构造函数传进去。 diff --git a/documents/vol8-domains/ai/tiny_ml/stage1/05-shape-in-type.md b/documents/vol8-domains/ai/tiny_ml/stage1/05-shape-in-type.md new file mode 100644 index 000000000..769b86b79 --- /dev/null +++ b/documents/vol8-domains/ai/tiny_ml/stage1/05-shape-in-type.md @@ -0,0 +1,89 @@ +--- +title: "形状塞进类型——维度为什么是模板参数" +description: "讲维度为什么进模板参数 Tensor 而非构造函数传入:编译期固定大小顺带不堆分配、类型系统当免费 shape checker 把形状错挪到编译期、维度编译期可见;代价是每种维度组合都是独立类型" +chapter: 8 +order: 11 +platform: host +difficulty: intermediate +cpp_standard: [23] +reading_time_minutes: 6 +prerequisites: + - "行主序——二维坐标怎么落进一维内存" +related: + - "固定维度 Tensor——推理器的数据底座" + - "为什么不用现成的——三个候选过堂" +tags: + - host + - cpp-modern + - intermediate + - 模板 + - 类型安全 +--- + +# 形状塞进类型——维度为什么是模板参数 + +[上一篇](./04-row-major.md)讲了“数怎么摆”(行主序),这篇讲最后一块:**这两个数 Rows 和 Cols,凭什么要写进模板参数 `Tensor`,而不是像普通对象那样,构造的时候传进去?** + +你可能会想,维度嘛,构造的时候传个 rows、cols 不就完了,干嘛非得塞进模板参数搞出一堆类型。这一步是 Tensor 设计里最容易被新手跳过、却又最值钱的一步——它把一整类“形状错”的 bug,从运行期挪到了编译期。 + +## 两种写法的对照 + +先看如果维度不进类型,会怎么写。大概是这种对新手友好的写法: + +```cpp +class Tensor { + float* data_; + int rows_, cols_; +public: + Tensor(float* data, int rows, int cols) + : data_(data), rows_(rows), cols_(cols) {} +}; +``` + +大小是运行期的 `int` 成员,构造时传进来。这是绝大多数“动态数组”类的写法,PyTorch 的 `torch::Tensor` 就是这样,形状运行期才知道。 + +咱们的写法是: + +```cpp +template +class Tensor { + std::array internals_{}; + // rows_/cols_ 不存在,它们就是模板参数 Rows、Cols 本身 +}; +``` + +大小不是成员,是类型的一部分。`Tensor<4, 3>` 和 `Tensor<2, 2>` 是两个完全不同的类型,编译期就定下来,运行期改不动。 + +## 好处一:编译期固定大小,顺带不堆分配 + +既然 Rows、Cols 是编译期常量,`Rows * Cols` 也是编译期常量,`std::array` 的大小编译期就定了。编译器知道这个对象多大,直接在栈上或静态区开出来,不需要 new,不需要 malloc。硬约束里的“编译期固定大小”和“不堆分配”,这一步同时满足。 + +反过来,运行期版本那个 `float* data_`,data 指向哪里?要么指向一块 new 出来的堆内存(撞“不堆分配”),要么指向外部传入的缓冲区(生命周期得自己管,容易出悬挂引用)。两条路都有坑。 + +## 好处二:类型系统帮你抓形状错(最值钱的地方) + +这才是维度进类型真正的威力。神经网络里大量错误本质上是形状错:权重矩阵的列数对不上输入向量的长度、两个矩阵相乘维度不匹配,等等。这些错如果形状是运行期的,只能运行期检查,崩了或返回个错误码你才知道。但如果形状是类型的一部分,**编译器在编译期就能替你挡住一大片**。 + +举个例子。Dense 层要求权重的列数等于输入的长度:权重 W 是 `Tensor<4, 3>`,输入 x 必须是 `Tensor<1, 3>`(那个 3 要对上)。如果哪天你手滑传了个 `Tensor<1, 5>` 的输入进去,编译器在编译阶段就拒掉,根本轮不到程序跑起来。 + +这种能力是动态形状的框架给不了的。PyTorch 那种运行期形状,维度不匹配要等跑到那一行代码、抛个 RuntimeError 才知道。咱们把维度塞进类型,等于让类型系统当免费的形状检查器。 + +具体怎么“塞”,Stage 2 写 Dense 的时候会用 `static_assert` 和模板约束把维度关系卡住,到时候你会看到它怎么在编译期挡错。这篇你只要先认下这个结论:**维度进类型,一整类形状错误就从运行期被挪到了编译期**。 + +## 好处三:维度编译期可见 + +顺带一个不那么起眼但很实在的好处。既然 Rows、Cols 是编译期常量,`row()`、`col()`、`size()` 这些查询函数就可以在编译期求值——咱们标的是 `constexpr`,够用了,写 `static_assert(tensor.size() == 12)` 直接能过。真想限定成“只能编译期求”再上 `consteval`,Stage 1 没这个刚需。 + +这对 Stage 5 也有用:权重要存成 `inline constexpr std::array`,它的形状得是编译期已知的常量,正好对得上 Tensor 这套“维度进类型”的设计。两边天然咬合。 + +## 代价:维度组合就是类型组合 + +诚实交代一下代价。维度进类型意味着,**每一种维度组合都是一个独立的类型**。`Tensor<4, 3>`、`Tensor<3, 4>`、`Tensor<2, 2>` 是三个互不相同的类型,写函数模板时它们是不同的实例化。 + +这对咱们 Lab 不是问题:MLP 的形状是固定的那几种(输入 1×3、权重 4×3 和 3×4、偏置 1×4 和 1×3),数得过来,类型膨胀可控。但如果你要写一个通用框架,接受任意形状、还要动态 reshape,这套“维度进类型”就不够用了,那得回到运行期形状的老路,PyTorch 就是这么选的。咱们是用“固定形状”换“编译期形状安全”,这笔交易对教学 Lab 划算,对通用框架不划算。各取所需。 + +## 五篇引入,到这就齐了 + +回头看这五篇:[01](./01-what-is-tensor.md) 把 Tensor 祛魅成一张二维数表,[02](./02-tensor-in-neural-network.md) 看它装输入/权重/偏置/输出四种数据,[03](./03-why-not-built-in.md) 否掉三个现成候选逼出方案,[04](./04-row-major.md) 把行主序布局定下来,这篇把维度塞进类型。Tensor 是什么、装什么、为什么这么造,五块拼图到这就齐了。 + +下一步是 [06-tensor.md](./06-tensor.md):把完整接口摆出来,讲三个剩下的小决策(at 返回值不返回引用、固定 2D 不做可变模板、CMake 怎么加库),然后照着接口草图动手写。前面这五篇的铺垫,就是为了让 06-tensor.md 里那些设计取舍读起来不再悬空。 diff --git a/documents/vol8-domains/ai/tiny_ml/stage1/06-tensor.md b/documents/vol8-domains/ai/tiny_ml/stage1/06-tensor.md new file mode 100644 index 000000000..783a388e5 --- /dev/null +++ b/documents/vol8-domains/ai/tiny_ml/stage1/06-tensor.md @@ -0,0 +1,220 @@ +--- +title: "固定维度 Tensor——推理器的数据底座" +description: "造一个编译期固定维度、行主序、std::array 存储的 Tensor,at 走 std::expected 返回值做无异常错误处理。三个设计决策:at 返回值不返回引用、固定 2D、行主序。配套工程 code/volumn_codes/vol8-labs/ai/tiny_ml/stage1/" +chapter: 8 +order: 12 +platform: host +difficulty: advanced +cpp_standard: [23] +reading_time_minutes: 12 +prerequisites: + - "工程脚手架——把工具链地基浇好" + - "模板与非类型参数" +tags: + - host + - cpp-modern + - advanced + - 模板 + - 内存管理 + - 类型安全 +--- + +# 固定维度 Tensor——推理器的数据底座 + +Stage 1 要造的是整个推理器的数据底座:一个编译期固定维度、行主序、`std::array` 存储的 `Tensor`,外加一套基于 `std::expected` 的无异常错误处理。做完这一 stage,你能用 `Tensor<1, 3>` 表达传感器输入、`Tensor<4, 3>` 表达 Dense 权重,但 Dense 本身留到 Stage 2 再写。配套工程在 `code/volumn_codes/vol8-labs/ai/tiny_ml/stage1/`。 + +这篇是 Stage 1 的实现主文档。如果你对 Tensor 这个概念本身、或者为什么非得自己造一个还陌生,先读[引入五篇](./index.md)再回来;已经清楚 Tensor 是什么、为什么这么设计的,这篇往下看就行。 + +## 为什么先造 Tensor + +Stage 1 把 v0.1 硬约束里最承重的三条凑在一起:无堆分配、不用 `std::vector`、编译期固定大小。Tensor 一旦定下来,Stage 2 的 Dense 就只是拿两个 Tensor 做乘加,Stage 5 的 NumPy 导出就只是往 `inline constexpr std::array` 里填数。Tensor 的布局是后面 Python 和 C++ 对拍的基础,这根线必须在 Stage 1 定下来,否则 Stage 5 的 golden test 永远对不上。 + +## 三个要先拍板的设计决策 + +### 决策一:at 返回 `std::expected` 的值,不返回引用 + +推理热路径用 `operator()`(返回引用、不检查,越界是 UB,要的是快)。但偶尔会在不确定边界的地方安全访问一个元素——越界了得带错误信息回来,不能 UB。这个“带检查版”的活,交给 `at`。 + +`at` 的返回类型用 `std::expected`(C++23)。第一反应可能是:返回引用才对吧,`std::array::at` 不就返回引用?但这里撞了 C++23 的一个硬约束:**`std::expected` 不接受 T 是引用类型**。`std::expected` 在标准里直接 `static_assert(!is_reference_v)`,编译不过(本机 g++ 16.1 实测会报一连串错,根因就是这条)。 + +所以“返回引用 + 走 expected 错误”这条路,标准层面就堵死了。剩下三条妥协:返回指针(`expected`)、包一层 `reference_wrapper`、或者干脆返回值。前两个要么接口丑(解引用多一步),要么多包一层对小白不好讲。最后选返回值:`at` 拿到的是元素的拷贝,越界走 expected 的错误路径。 + +代价是 `at` 改不了原元素——`t.at(0,0).value() = 5.f` 改的是 expected 里的临时拷贝,原元素不动。但这是设计意图:`at` 在咱们 Lab 里定位是“带检查的安全读取”,要改元素走 `operator()`。推理热路径全是 `operator()`,`at` 只是测试和校验时用,返回值够用,这点代价换不来什么收益去推翻它。 + +### 决策二:固定 2D,不做可变模板 + +维度进类型有什么好处,[引入 05 篇](./05-shape-in-type.md)讲过了,这里只讨论进了类型之后,做固定 2D 还是可变 N 维。v0.1 的 MLP 全程只有两种形态:行向量(输入、输出、偏置)和 2D 矩阵(权重)。一个固定 2D 的 `Tensor` 就全覆盖了,没必要上可变模板 `Tensor`。那东西写起来陡峭,MLP 又根本用不上 3D+,属于为不存在的要求付复杂度税。1D 向量直接用别名 `Vector = Tensor<1, Cols>`,Argmax 和 Dense 的输入输出都走它,语义统一。 + +### 决策三:`std::array` 存储 + 行主序 + +存储没得选,硬约束禁了 `std::vector`,只剩 `std::array`,三个候选的过堂见[引入 03 篇](./03-why-not-built-in.md)。布局走**行主序** `internals_[i*Cols + j]`,跟 NumPy 默认的 C order 一致,这样 Stage 5 的 Python 权重和 C++ Tensor 能一位对一位地对拍,Python 的 `W[i, j]` 和 C++ 的 `W(i, j)` 指向同一个数。行主序的完整推导和内存图在[引入 04 篇](./04-row-major.md),这里不重复。 + +## 实现指引 + +### 接口草图(跟工程代码对齐) + +工程里就一个头 `include/tinyml/tensor.hpp`,签名长这样(实现见工程,这里讲要点): + +```cpp +#pragma once +#include +#include +#include +#include + +namespace tamcpp::tinyml { + +template +class Tensor { + public: + // 错误码挂类内:每个维度的 Tensor 各带一份,够用不折腾 + enum class Error { kShapeMismatch, kOutOfRange }; + + // 带检查的安全读取:越界走 expected 错误,不抛异常 + constexpr std::expected + at(std::size_t i, std::size_t j) noexcept { + if (i >= Rows || j >= Cols) return std::unexpected{Error::kOutOfRange}; + return internals_[i * Cols + j]; + } + constexpr std::expected + at(std::size_t i, std::size_t j) const noexcept; // 同上,const 版 + + static_assert(Rows > 0 && Cols > 0, "dims must be positive"); + + constexpr Tensor() = default; + constexpr Tensor(std::array internals) + : internals_(std::move(internals)) {} + + constexpr std::size_t row() const noexcept { return Rows; } + constexpr std::size_t col() const noexcept { return Cols; } + constexpr std::size_t size() const noexcept { return internals_.size(); } + + // 热路径访问:返回引用、不检查,越界 UB + constexpr StorageType& operator()(std::size_t i, std::size_t j) noexcept; + constexpr const StorageType& operator()(std::size_t i, std::size_t j) const noexcept; + + // 扁平 span 视图(Stage 2 Dense 读权重用,不拷贝) + constexpr std::span view() const noexcept; + constexpr std::span view() noexcept; + + constexpr std::array& storage() noexcept; + constexpr std::array& storage() const noexcept; + + private: + std::array internals_{}; // 这个 {} 不能省,见常见坑 +}; + +template +using Vector = Tensor<1, Cols, StorageType>; + +} // namespace tamcpp::tinyml +``` + +几个容易看走眼的地方点一下。 + +模板参数顺序是 ``,**维度在前、类型有默认值**。所以写 `Tensor<4, 3>` 就是 `Tensor<4, 3, float>`——维度才是 Tensor 身份的核心,放前面,类型默认 float 能省一半书写。 + +`row()` / `col()` / `size()` 用 `constexpr` 就够(`static_assert(tensor.size() == 12)` 能过),不必非要 `consteval`。`constexpr` 允许运行期也调,宽松一点;真要限定编译期再上 `consteval` 也不迟。 + +`at` 是 `noexcept` 的——它走 expected 的错误路径,不抛异常,符合核心路径无异常的约束。注意它返回值不是引用,理由见决策一。 + +**构造函数为什么没标 noexcept。** `at`、`operator()` 这些函数标了 `noexcept`,默认构造 `Tensor() = default` 和那个接 `std::array` 的构造函数却没标。差别在:访问类函数只做下标比较和取元素(对 float,拷贝不抛),确实不抛,标 `noexcept` 没风险;构造函数要真的去构造成员 `internals_{}`,这步抛不抛,得看 StorageType 自己的构造。所以构造的 `noexcept` 跟 StorageType 挂钩,这里干脆交给编译器——`= default` 会按“成员构造抛不抛”自动配上隐式异常说明,本机 g++ 16.1 实测 `Tensor<4, 3, float>` 的默认构造被推成 `noexcept(true)`。没把 noexcept 写在脸上,不等于会抛;对 float 它就是不抛,只是让编译器替你认下这件事。源码里 `// Q: why not noexcept?` 那句问的就是它。 + +### CMake:INTERFACE 库 + 跨编译器警告封装 + +工程顶层 `CMakeLists.txt` 把推理库声明成 `INTERFACE`(纯头库,`Tensor` 全 inline 在头里,没有 `.cpp` 产物): + +```cmake +add_library(TAMCPP_TinyML INTERFACE include/tinyml/tensor.hpp) +target_include_directories(TAMCPP_TinyML INTERFACE ${CMAKE_CURRENT_SOURCE_DIR}/include) +``` + +警告标志跨编译器不通用(MSVC 一套、GCC/Clang 一套),封一个函数复用,别在每个 target 里抄 generator-expression: + +```cmake +function(tamcpp_target_warnings target) + target_compile_options(${target} PRIVATE + $<$:/W4;/permissive-;/Zi> + $<$>:-Wall;-Wextra;-Wpedantic;-g> + ) +endfunction() +``` + +测试 target 同样封一层糖,`tests/CMakeLists.txt` 每个测试一行注册: + +```cmake +function(tamcpp_add_test name source) + add_executable(${name} ${source}) + target_link_libraries(${name} PRIVATE Catch2::Catch2WithMain TAMCPP_TinyML) + tamcpp_target_warnings(${name}) + catch_discover_tests(${name}) +endfunction() + +tamcpp_add_test(smoke_catch2 smoke.cpp) +tamcpp_add_test(tensor_api tensor_api.cpp) # ← 这个别漏,见常见坑 5 +``` + +还有一句容易忘的:顶层要 `enable_testing()`,否则 `catch_discover_tests` 注册的 `add_test` 全悬空,`ctest` 永远 "No tests found"。 + +## 验证 + +`tests/tensor_api.cpp` 的几个 case 是 Stage 1 “真过了”的判据,尤其行主序那条,是 Stage 5 对拍的前置: + +```cpp +TEST_CASE("dims are compile-time visible", "[tensor]") { + Tensor<4, 3> tensor; + static_assert(tensor.size() == 12); + static_assert(tensor.row() == 4); + static_assert(tensor.col() == 3); +} + +TEST_CASE("construct and access", "[tensor]") { + Tensor<2, 2> t(std::array{1.f, 2.f, 3.f, 4.f}); + REQUIRE(t(1, 0) == 3.f); +} + +TEST_CASE("row-major layout matches flat storage", "[tensor]") { + Tensor<2, 2> t(std::array{1.f, 2.f, 3.f, 4.f}); + for (std::size_t i = 0; i < 2; ++i) + for (std::size_t j = 0; j < 2; ++j) + REQUIRE(t(i, j) == t.storage()[i * 2 + j]); + REQUIRE(t.view().front() == t.storage()[0]); +} + +TEST_CASE("vector is a row tensor", "[tensor]") { + Vector<3> v; + static_assert(v.row() == 1 && v.col() == 3); +} + +TEST_CASE("out-of-range goes through expected", "[tensor]") { + Tensor<2, 2> t; + auto r = t.at(99, 99); + REQUIRE_FALSE(r); + REQUIRE(r.error() == Tensor<2, 2>::Error::kOutOfRange); + + // 单维越界也要拦(防 && 回归:i 越界但 j 在范围内) + auto r_single = t.at(99, 0); + REQUIRE_FALSE(r_single); + REQUIRE(r_single.error() == Tensor<2, 2>::Error::kOutOfRange); +} + +TEST_CASE("default construction is zero-initialized", "[tensor]") { + Tensor<2, 2> t; + REQUIRE(t(0, 0) == 0.f); +} +``` + +```bash +cmake -S . -B build && cmake --build build -j +ctest --test-dir build +``` + +6 个 case 全绿就算过。行主序那条尤其要看,它定下了和 NumPy 的对齐关系,这条过不了,Stage 5 的对拍就没了基础。 + +## 常见坑 + +1. **at 越界检查用 `||` 不是 `&&`**:`if (i >= Rows && j >= Cols)` 要求 i、j **都**越界才报错,单维越界(`at(99, 0)`)直接漏过去访问 `internals_[198]`,撞 `std::array` 越界断言。写成 `||`,i 或 j 任一越界就返回错误。这条本机 ASAN 实证过。 +2. **`internals_{}` 的 `{}` 不能省**:成员没写 `{}` 时,`Tensor<2,2> t;` 的 default 构造让 `std::array` 元素处于 indeterminate,`t(0, 0)` 读的是未初始化垃圾(UB)。测试能过是栈上垃圾凑巧是 0,撞大运。本机 msan 实证 `use-of-uninitialized-value`。加上 `{}` 才 value-init(float 就是 0.0f),default 构造名副其实。 +3. **`std::expected` 不接引用类型**:`expected` 编译不过(标准 `static_assert(!is_reference_v)`)。所以 at 没法“返回引用 + 走 expected 错误”,只能返回值或指针。咱们选值,理由见决策一。 +4. **CTAD 会丢维度**:`Tensor t(std::array{...})` 这种类模板参数推导会把 `Rows` / `Cols` 全丢了,必须显式写 `Tensor<2, 2>`。别指望 CTAD 帮你。 +5. **`tests/CMakeLists.txt` 要注册测试 target**:写了 `tensor_api.cpp` 但忘了 `tamcpp_add_test(tensor_api tensor_api.cpp)`,构建根本不会编译它,你跑的 `ctest` 只有 smoke。新加测试文件记得在这儿注册一行。 diff --git a/documents/vol8-domains/ai/tiny_ml/stage1/index.md b/documents/vol8-domains/ai/tiny_ml/stage1/index.md new file mode 100644 index 000000000..9463e5f14 --- /dev/null +++ b/documents/vol8-domains/ai/tiny_ml/stage1/index.md @@ -0,0 +1,29 @@ +--- +title: "Stage 1 · 固定维度 Tensor" +description: "推理器的数据底座。Tensor 概念引入(01-05)+ 实现主文档(06-tensor.md)" +platform: host +tags: + - cpp-modern + - host + - intermediate +--- + +# Stage 1 · 固定维度 Tensor + +这一 stage 造整个推理器的数据底座:一个编译期固定维度、行主序、`std::array` 存储的 `Tensor`。 + +如果你对 Tensor 这个概念本身还陌生,先读 01-05 五篇引入,它从"Tensor 是什么"一路讲到"为什么这么设计";已经熟悉 Tensor、只想看工程实现的,可以直接跳到 [06-tensor.md](./06-tensor.md)。 + +## 引入:Tensor 是什么、为什么这么造 + +1. [Tensor 是什么——先把名字从神坛上拿下来](./01-what-is-tensor.md) —— 祛魅:它就是一张固定大小的二维数表 +2. [Tensor 在神经网络里装什么——四种数据,一种容器](./02-tensor-in-neural-network.md) —— 输入 / 权重 / 偏置 / 输出,全是 Tensor +3. [为什么不用现成的——三个候选过堂](./03-why-not-built-in.md) —— vector、原生数组、嵌套 array 各死在哪 +4. [行主序——二维坐标怎么落进一维内存](./04-row-major.md) —— `i*Cols + j`,跟 NumPy 对齐 +5. [形状塞进类型——维度为什么是模板参数](./05-shape-in-type.md) —— 类型系统当免费的形状检查器 + +## 实现:动手写 Tensor + +- [固定维度 Tensor——推理器的数据底座](./06-tensor.md) —— 完整接口草图、`std::expected` 错误处理、CMake、验证测试、常见坑 + +读完引入五篇,06-tensor.md 里那些设计取舍读起来就不再悬空了。 diff --git a/documents/vol8-domains/index.md b/documents/vol8-domains/index.md index ff9d5065d..9cd1f194c 100644 --- a/documents/vol8-domains/index.md +++ b/documents/vol8-domains/index.md @@ -14,10 +14,11 @@ tags: ## 概述 -本卷覆盖现代 C++ 在各领域的实际应用,包含六个子领域: +本卷覆盖现代 C++ 在各领域的实际应用,包含七个子领域: - **嵌入式开发**:资源约束、零开销抽象、外设编程、RTOS、STM32 实战 - **网络编程**:Socket、HTTP、异步 I/O、WebSocket、RPC +- **AI 与 TinyML**:用现代 C++ 从零手搓玩具级神经网络推理器,理解 TinyML 推理在 MCU 上怎么跑 - **GUI 与图形**:图形基础、最小 GUI 框架、ImGui、2D/3D 渲染 - **数据存储**:序列化、文件格式、SQLite、键值存储 - **算法与数据结构**:复杂度分析、经典算法、高级数据结构 @@ -30,6 +31,7 @@ tags: 嵌入式开发 网络编程 + AI 与 TinyML GUI 与图形 — 规划中 数据存储 — 规划中 算法与数据结构 — 规划中