公众号

点击「查看原文」跳转到 GitHub 上对应文件,链接就可以点击了
qq群 753792291 答疑在这里
欢迎投稿,推荐或自荐文章/软件/资源等,评论区留言
股票亏麻了,回归更新周刊,大家有钱给我打点T_T
感谢前方记者mick
欢迎来到C++29的第一次会议,C++29的第一批特性正式诞生!在C++26 DIS于上次会议正式签发后,C++29草案终于开放,不少一直卡在pipeline里的特性也终于得以前进。
本次会议共通过了20篇语言提案和19篇库提案——这是一个十分神奇的结果,关注以前的会议简报的人应该知道,一般库提案总是远多于语言提案的;事实上,本次会议是2011年到现在第一次出现语言提案比库提案多的情况。究其原因,大量的pointer zap等和普通用户关系不大的语核修改提案撑起了相关的数量;话虽如此,本次会议通过的提案依然有不少亮点。语言层面,对普通用户影响最大的提案毫无疑问是P3668默认的后置自增/自减操作符——终于我们不用一遍遍重复{T temp{*this}; ++temp; return *this;}三段式了,现在直接写T operator++(int) = default;即可。除了减少重复代码以外,本特性一己之力从标准库中去掉了200+行wording,也是罕见的能够造成标准有意义缩减的特性。语言层面争议最大的提案莫过于P3097 pre/post on virtual functions,在C++26最后时刻被从P2900移除后,此特性这次卷土重来以同样的模型重新加入了标准,但不少重量级人物已经表达了对此特性不成熟以及偏离其他语言先例的不满,在未来的一年里想必我们将看到此方面精彩的交锋。除此之外,P3950的光速通过让协程终于可以同时支持co_return;和co_return value;,解除了stdex::task的一个小小的可用性障碍。此外的亮点包括P4101将反射的限制模型从consteval-only type转向更合理的value,以及P3540为# embed补上了各大实现均已提供的非标准offset参数。较为遗憾的是,原本进展顺利的fiber_context和编译期协程两个重量级提案最终未能完成review,只能留待下次会议决策。此外,本次会议还通过了一个重要的editorial改动:标准草案将从本次会议开始提供Annex F,系统地整理了核心语言中的所有未定义行为和IFNDR,并为每个行为添加示例,也让我们得以直接观察C++对安全的努力。
标准库层面,最重要的改动莫过于P3091 map::lookup,终于为标准关联容器提供了一个const可用的查询手段。在以前,由于[]可能造成插入,查询const map只能通过find或者lower_bound等间接手段。随着optional<T&>加入标准,我们终于有了兼顾引用和可空的手段,从而optional<V&> lookup(const K&)的签名也成为了顺理成章。与此同时,此特性也让pre/post中的map使用终于变方便了。同样重要的是P2019 Thread Attributes,让我们终于有了可移植地为std::thread指定名字和栈大小的手段。除此之外,本次会议还通过了一些
Stage 2工作组方面,EWG延续了一向的高效率,由于语言新提案相对较少仅用了三天就完成了所有提案的review,并获得了罕见的休息日权利。其中,和C2y保持一致的break/continue with label特性,万众瞩目的std::embed以及pre/post的自定义消息支持均终于进入了Stage 3,而一些大提案也获得了相应的进展。本次会议争吵最大的提案莫过于_BitInt(N)的C++支持,由于上次会议EWG和LEWG投票结果出现了自相矛盾的情况,本次会议两个大组被叫到了一起来做该提案的最终决策,并推翻了上次会议的决定最终还是承认了此类型的整型身份,这也意味着format/to_chars等标准库支持看来是逃不掉了。LEWG方面,在整个组为Lakos Rule和cstring_view大吵两架却什么都没决定后,只得承认SD-9的阶段性失败并暂时将此文件雪藏。Stage 1方面,只有SG1/6/9/23在这次会议取得了有意义的进展,其中SG6发现了单位库存在的不少问题,并正在抓紧修改以期让mp-units依然能赶上C++29的末班车。SG9则终于将已经在Stage 1卡了十五个revision的utf_view送出去了,并开始了ranges::reduce等算法的初步讨论。遗憾的是,大家期待的模式匹配和网络库在本次会议并没取得任何进展,再不抓紧的话或许又要错过一班车了。
在本次会议后,WG21将迎来超过五个月的休息期,并在下一次会议历史首次来到南美洲开会,标准的各项动态也将迎来一段时间的沉寂期。希望各大提案能抓紧这个机会好好改进吧!
这篇很硬核,作者想手搓一个 copy_if 的 SIMD 版本,结果一头撞进了微架构分析的大坑里。省流:第一版 AVX-512 代码虽然 branchless,但在 Zen 4 上慢得离谱,根因不是“SIMD没用”,而是 vpcompressd 的 memory form 在 Zen 4 上是重 microcode 指令。
先看原始 std::copy_if 语义:
template<class InputIt, class OutputIt, class UnaryPred>
OutputIt copy_if(InputIt first, InputIt last, OutputIt d_first, UnaryPred pred) {
for (; first != last; ++first)
if (pred(*first)) {
*d_first = *first;
++d_first;
}
return d_first;
}
作者第一版 AVX-512 核心写法:
const auto v_in1 = _mm512_loadu_epi32(input + i);
const auto v_in2 = _mm512_loadu_epi32(input + i + vlen);
const __mmask16 m1 = vp(v_in1);
const __mmask16 m2 = vp(v_in2);
_mm512_mask_compressstoreu_epi32(out, m1, v_in1);
const auto d1 = _mm_popcnt_u32(m1);
_mm512_mask_compressstoreu_epi32(out + d1, m2, v_in2);
const auto d2 = d1 + _mm_popcnt_u32(m2);
out += d2;
修复版改成寄存器压缩 + 普通 store:
const auto v_out1 = _mm512_maskz_compress_epi32(m1, v_in1);
const auto v_out2 = _mm512_maskz_compress_epi32(m2, v_in2);
_mm512_storeu_epi32(out, v_out1);
out += _mm_popcnt_u32(m1);
_mm512_storeu_epi32(out, v_out2);
out += _mm_popcnt_u32(m2);
作者一开始用了 _mm512_mask_compressstoreu_epi32 直接压缩写回,看起来非常优雅:load、比较出 mask、compress store、popcnt 更新输出指针,一气呵成。但 benchmark 出来吞吐只有 约 4 GB/s,和机器上 copy 的上限 26.5 GB/s 到 300 GB/s 比起来完全没法看。
文里 likwid 的用法是先测“机器上限带宽”,作者给了可复现脚本:
for size in 16kB 64kB 256kB 1MB 4MB 16MB 64MB 256MB 1GB 4GB; do
bw=$(likwid-pin -c 1 likwid-bench -t copy_avx512 -w S0:${size}:1 2>/dev/null \
| grep "MByte/s" | awk '{print $NF}')
echo "$size $bw"
done
for size in 16kB 64kB 256kB 1MB 4MB 16MB 64MB 256MB 1GB 4GB; do
bw=$(likwid-pin -c 1 likwid-bench -t copy -w S0:${size}:1 2>/dev/null \
| grep "MByte/s" | awk '{print $NF}')
echo "$size $bw"
done
然后 perf 走 top-down 两层拆解。
Level 1(先看大类):
sudo perf stat -M backend_bound,bad_speculation,frontend_bound,retiring \
-- chrt -f 50 taskset -c 0 \
./build/benchmarks/ckl_algorithm_bench \
--benchmark_filter='BM_CopyIf_Ckl/16777216' \
--benchmark_min_time=3s
Level 2(再细分 FE/BE 和 microcode):
sudo perf stat -M backend_bound_memory,backend_bound_cpu,frontend_bound_bandwidth,frontend_bound_latency,retiring_fastpath,retiring_microcode \
-- chrt -f 50 taskset -c 1 \
./build/benchmarks/ckl_algorithm_bench \
--benchmark_filter='BM_CopyIf_Ckl/16777216' \
--benchmark_min_time=3s
最后再用 IBS 做指令归因(AMD 平台这一步很关键,能降低 skid 干扰):
sudo perf record -F 3977 --call-graph fp -e ibs_op/cnt_ctl=1/pp \
-- chrt -f 50 taskset -c 1 \
./build/benchmarks/ckl_algorithm_bench \
--benchmark_filter='BM_CopyIf_Ckl/16777216' \
--benchmark_min_time=3s
perf annotate
文里这一步最后归因到 vpcompressd,占大头(接近 95% 量级的 retired uops 归因)。省流:不是 popcnt 慢,也不是 load 慢,主要是 vpcompressd memory form 在 Zen 4 上太贵。
文中结论非常直接:Level 1 显示 frontend_bound 很高;Level 2 显示 retiring_microcode 很高。这就把怀疑目标收敛到 vpcompressd 这种可疑指令上,再配合 IBS 采样定位,基本实锤。
后面用 perf stat 做 top-down 分析,发现 65% frontend bound,其中大量 retiring microcode;再用 AMD IBS 采样定位,发现 95% 的 retired microcode uops 都落在 vpcompressd 上。解决方案也很直接:别用写内存版的 compress,改成先 _mm512_maskz_compress_epi32 压到寄存器,再普通 store。缺点是会写一些“垃圾字节”,但后续会被覆盖。修完以后,速度提升到 10x 到 40x。
这篇最值钱的不是“copy_if SIMD化”,而是怎么系统地查 SIMD 程序为什么没快:likwid 定上限,perf 看 top-down,IBS 定位热指令。手写 intrinsic 之前先去 uops.info 查一眼,真的能少踩很多坑。值得复现一下。
NVIDIA 这篇是在推 CUDA 13.3 里的 CUDA Tile C++。核心想法是:别再手写“每个线程干什么”,直接写 tile 上的数学操作,让编译器帮你安排块内并行、内存搬运、异步这些底层细节。
最小例子就是 vector add。传统 CUDA 要你自己算 threadIdx.x + blockIdx.x * blockDim.x,再做边界判断:
__global__ void vecAdd(float* A, float* B, float* C, int N) {
int workIndex = threadIdx.x + blockIdx.x * blockDim.x;
if (workIndex < N) {
C[workIndex] = A[workIndex] + B[workIndex];
}
}
Tile C++ 版则是“先切 tile,再做数学”:
__tile_global__ void vectorAdd(float* a, float* b, float* out, std::size_t n) {
namespace ct = cuda::tiles;
using namespace ct::literals;
auto aView = ct::partition_view{ct::tensor_span{a, ct::extents{n}}, ct::shape{1024_ic}};
auto bView = ct::partition_view{ct::tensor_span{b, ct::extents{n}}, ct::shape{1024_ic}};
auto oView = ct::partition_view{ct::tensor_span{out, ct::extents{n}}, ct::shape{1024_ic}};
int bx = ct::bid().x;
auto oTile = aView.load_masked(bx) + bView.load_masked(bx);
oView.store_masked(oTile, bx);
}
文章还给了 matrix multiply 的写法,用 ct::mma 在 tile 上累计结果,整体可读性比传统 shared-memory matmul 友好不少。编译命令也很明确:nvcc -std=c++20 --enable-tile -arch sm_120。要求是 CUDA 13.3、8.x 及以上 GPU、对应较新的驱动。
省流:这东西本质上是 NVIDIA 给 C++ 端也塞了一个更高层的 kernel DSL。写 demo 很香,真上复杂 kernel 还得看看调优空间和可控性,但方向是对的。
这篇是从 ref qualifier 讲到 deducing this 的过渡文。作者的观点很直接:ref qualifier 能精细区分左值/右值对象状态,但容易带来重复声明和重复实现;deducing this 在很多场景里就是更通用、更优雅的替代品。
比如以前你会写 f() &、f() &&,甚至 f() const && = delete 来限制调用对象的值类别。现在可以写成:
void member_func(this auto&& self) {
// 根据 self 的值类别做分支
}
如果你不走 generic 这条路,也可以直接写成具体类型:
Logger&& setLogLevel(this Logger&& self, loglevel ll) {
self.level = ll;
return std::move(self);
}
不过作者也提醒了一点:generic deducing this 不能完全替代 ref qualifier 的所有边角能力。像 const && = delete 这种“专门把某个奇怪状态判死刑”的玩法,ref qualifier 还是更直接。简单来说,C++23 够新的项目可以优先考虑 deducing this;老标准或者特别强调语义清晰的地方,ref qualifier 继续用也完全合理。
这是 CUDA 13.3 的总览文章。对 C++ 用户来说,重点有几个。
第一,CUDA Tile C++ 正式发布,前面那篇已经展开讲了。第二,CompileIQ 上线,NVIDIA 说它会用进化/遗传算法帮你调编译器配置,在 GEMM、attention 这种核上能有 最高 15% 的提升。第三,nvcc 和 nvrtc 开始更完整地拥抱 C++23。
Python 生态也动静很大。cuda.core 稳到 1.0,带来 green contexts、process checkpointing、IPC 共享显存;cuda.compute 把 CCCL 里的 sort/scan/reduce 之类并行算法直接带到 Python;Numba CUDA MLIR 后端也在推,JIT 编译和 launch latency 都在降。
这篇总览里也给了不少真代码,先看 cuda.core 的最小链路:
from cuda.core import Device, Stream, Program, ProgramOptions, LaunchConfig, launch
# pick and activate a GPU
dev = Device()
dev.set_current()
# create a CUDA stream
stream = dev.create_stream()
# NVRTC compile + lookup
prog = Program(src, code_type="c++", options=ProgramOptions(arch=f"sm_{dev.arch}"))
kernel = prog.compile("cubin").get_kernel("my_kernel")
# launch a kernel
launch(stream, LaunchConfig(grid=64, block=256), kernel, *args)
cuda.compute 这边是并行算法直出:
import cuda.compute
from cuda.compute import OpKind
cuda.compute.reduce_into(
d_input, d_output,
OpKind.PLUS, d_input.size, h_init
)
cuda.compute.reduce_into(
d_input, d_output,
lambda a, b: a if a > b else b,
d_input.size, h_init,
)
CCCL 里还给了 FindIf 示例:
cub::DeviceFind::FindIf(
d_temp, temp_bytes, input, output,
[] __device__ (int value) {
return value > 42;
}, num_items);
还有一些零散但挺实用的更新,比如 DLPack 和 mdspan 互转、cub::DeviceFind::FindIf 最多 7x 提速、更多随机数分布、Green Context 默认流创建可选、MPS partial error isolation 等等。简单来说,这次更新不是“加一个 API 完事”,而是 C++、Python、编译器、工具链一起往前拱。真心猛。
这篇讲的是一个很抽象,但真会把你 concept overload 搞炸的坑。问题是:C++23 里 fold expression 里的约束在 subsumption 看来基本是黑盒。也就是说,哪怕 (C<T> && ...) 明显比 (A<T> && ...) 更严格,编译器也可能看不出来,于是重载解析直接二义性。
文章用这个小例子解释:
template <class T> concept A = std::is_move_constructible_v<T>;
template <class T> concept B = std::is_copy_constructible_v<T>;
template <class T> concept C = A<T> && B<T>;
template <class... T> requires (A<T> && ...) void g(T...);
template <class... T> requires (C<T> && ...) void g(T...);
按人脑理解第二个更严格,但 C++23 里这俩都被当成 fold 原子约束,经常直接二义性。P2963R3 的修复是让 fold 也参与可比较的 subsumption。
P2963R3 的修复方法是引入 fold expanded constraints,让编译器能比较 fold 里的模式本身,而不是把整个表达式当石头。前提是 fold 运算符必须一致,&& 不能去 subsume ||。这类改动平时没啥存在感,但对写 variadic concepts overload 的人来说,这就是“终于像人话了”。
Raymond Chen 这两篇在讲一个很实际的问题:多个 coroutine 想共享同一个 WinRT IAsyncOperation 的结果,只做一次真正工作,怎么写才不炸。
第一篇主要是拆用户的初版实现。思路是用 m_busy、m_finished 事件和 m_task 共享状态,让第一个调用者启动任务,后面的人等事件。但这个实现有一堆坑:completion lambda 改 m_busy 没上锁,GetThingWorkerAsync 抛异常会把状态卡死,还存在任务完成和二次启动之间的 race,严重时会把 m_task 覆盖掉,后面读到错任务结果。
原文里那段“不要这么写”的代码核心是这个结构:
// Don't use this code. See discussion.
winrt::Thing m_thing{ nullptr };
winrt::IAsyncOperation<winrt::Thing> m_task{ nullptr };
wil::unique_event m_finished{ wil::EventOptions::ManualReset };
bool m_busy{ false };
std::mutex m_mutex;
if (shouldStart) {
auto task = GetThingWorker();
m_finished.ResetEvent();
task.Completed([weak = get_weak(), this](auto&&, auto&&) {
if (auto strong = weak.get()) {
m_busy = false;
m_finished.SetEvent();
}
});
m_task = std::move(task);
}
co_await winrt::resume_on_signal(m_finished.get());
省流:自己拿事件+忙标志拼 async once,特别容易拼出竞态条件。看着没几行,细节全是雷。
第二篇给了一个明显更靠谱的版本:直接把函数序列化,而不是手搓“共享任务状态机”。
核心代码其实很短:
struct Widget : WidgetT<Widget> {
std::optional<winrt::Thing> m_thing;
wil::unique_event m_busy{ wil::EventOptions::Signaled }; // auto-reset
IAsyncOperation<winrt::Thing> GetThingAsync() {
auto lifetime = get_strong();
co_await winrt::resume_on_signal(m_busy.get());
auto not_busy = m_busy.SetEvent_scope_exit();
if (!m_thing) {
m_thing = co_await GetThingWorkerAsync();
}
co_return *m_thing;
}
};
做法是用一个 auto-reset event,当作“当前是否有人在执行 GetThingAsync”的门闩。进函数先 co_await resume_on_signal 拿门票,离开作用域时自动 SetEvent 放行下一个。缓存值用 std::optional<Thing> 表示“还没有结果”,如果 worker 成功返回 nullptr 也能和“尚未初始化”区分开。
这个版本简单很多,而且异常语义也顺:失败就抛,缓存保持空,下一次还能再试。说白了,代码短不是目的,状态少才是目的。
Andreas Fertig 这篇继续玩 C++26 reflection,这次的目标很接地气:把嵌套 struct 递归地做原地序列化/byteswap,适合网络字节序那类 embedded 老活。
前半段都是老问题:数组得递归处理,enum 要 to_underlying 再 byteswap,普通标量直接 std::byteswap。真正难的是类类型成员。以前要么手写 visitor,要么宏展开,反正都不优雅。
有了 reflection 之后,Serialize(T&) 可以对 class 的所有 data members 反射遍历,然后递归调用 Serialize。文章结构大概是这样:
template<typename T>
requires (!std::is_pointer_v<T>) && std::is_trivially_copyable_v<T>
constexpr void Serialize(T& data) {
if constexpr (std::is_array_v<T>) {
for (int i{}; i < std::size(data); ++i) Serialize(data[i]);
} else if constexpr (std::is_enum_v<T>) {
auto u = std::to_underlying(data);
u = std::byteswap(u);
data = static_cast<T>(u);
} else if constexpr (!std::is_class_v<T>) {
data = std::byteswap(data);
} else {
// C++26 reflection: 遍历成员并递归 Serialize
}
}
这就把“字段枚举”从人工维护变成了编译器维护。省流:反射最先落地的地方大概率不是花里胡哨的元编程,而是这种重复得要死的序列化/转换代码。
这篇接着上一篇继续聊,问题更具体:如果用 generic deducing this,能不能借 concepts 把 ref qualifier 的那套“只允许右值调用”也补回来?
答案是可以。比如作者定义一个 deducing_this_rvalue_only concept,本质就是检查 T 是否是 rvalue reference,然后把它用在 this auto&& self 上。这样你就能写出“只有右值对象能调”的成员函数,而且 concept 名字还能把错误信息变得稍微人能看懂一点。
文里给的关键代码是:
template<class T>
concept deducing_this_rvalue_only = std::is_rvalue_reference_v<T>;
Logger&& setLogLevel(this deducing_this_rvalue_only auto&& self,loglevel ll){
self.level=ll;
return std::move(self);
}
当然,作者自己也承认,这么写不一定总比 ref qualifier 清爽。要复用、要自定义报错、要把限制和模板体系揉一块,那 concept 值得上;如果你只是想写个 fluent API 的 rvalue-only setter,&& qualifier 可能还是最直白。简单来说,能做,不一定非要做。
这是一篇超长 concepts 教程,覆盖面非常全,适合从“会用一点 requires”到“把概念这套语法真搞明白”。
它先从 unconstrained template 的老问题讲起:接口不明确、错误信息脏、SFINAE 像天书。然后一路铺到 named concepts、leading/trailing requires、requires 有无花括号的区别、用 concepts 做模板“接口”、重载选择、member function specialization、generic lambda 约束、variadic concepts、if constexpr 里临时写 requires 等等。
几个关键示例都值得抄:
template<typename T>
concept Number = std::integral<T> || std::floating_point<T>;
template<Number T>
T getMedianNumber(std::vector<T> values) {
std::sort(values.begin(), values.end());
return values[values.size() / 2];
}
template<typename T>
concept Motor = requires(T m) {
T{int{}, int{}};
m.start();
m.stop();
};
if constexpr (requires { requires std::is_integral_v<T>; }) {
// 真正检查 T 是否 integral
}
我觉得最有用的是作者反复强调一件事:requires(expr) 和 requires { statements; } 不是一回事。前者是约束形成/失效,后者是检查这些语句是否 well-formed 并返回布尔值。很多人第一次看 concepts 绕来绕去,基本都死在这一步。想系统补课的可以看这篇。
这篇是模块构建系统吐槽文,但吐得很到位。核心观点就一句:很多构建系统说自己“支持 modules”,其实只支持扫描 import/export,不支持 BMI compatibility。
作者给了一个简单测试:provider 用 C++23,三个 consumer 里一个也用 C++23,两个用 C++26。真正的问题不是“能不能 build 通”,而是 build system 能不能识别哪些 BMI 可复用、哪些必须重建。因为 BMI 比 object file 脆弱太多,甚至同一个编译器不同 flags 都可能不兼容。
最小例子其实就够让构建系统头疼:
// provider.cppm
export module provider;
// consumer.cpp
import provider;
如果 provider 的 BMI 是用 -std=c++23 产的,而 consumer 走 -std=c++26,很多系统只会看到“import provider”,却看不见“这份 BMI 其实不兼容,必须重建 provider”。
测试结果也挺有节目效果:一堆新老构建系统翻车,Bazel 模块支持还在实验阶段,CMake 和 Xmake 能构建但各有浪费。更扎心的是,连“BMI 不兼容了以后该怎么重建才算对”这件事,行业里都没有统一答案。模块难,真不是编译器开关一开就完事。构建系统作者看了估计要掉头发。
Raymond 这个 rotate 系列还挺好玩。这篇讲 gcc/libstdc++ 针对 random-access iterator 的 std::rotate 一个单向算法:假设左块 A 比右块 B 小,就一路交换 first 和 mid 指向的元素,先把 A 换出,再递归处理剩下那截被打乱的 A。
可以把它理解成下面这个“循环+递归”骨架:
while (mid != last) {
std::iter_swap(first, mid);
++first;
++mid;
}
// 剩下那段继续 rotate
文章给出的关键结论是:这个算法总共做 n - 1 次 swap,locality 也还不错,因为 first 基本单调往前走,mid 大部分时间也往前,只在递归阶段回跳一点。逻辑上其实就是“不断把较短块往最终位置拽”。
这篇的“震惊发现”其实不怎么震惊:gcc 的这个 random-access rotate 算法,本质上和 forward iterator 版本是同一个算法,只是视角不一样。
前者看起来像不停往后 swap,后者看起来像递归交换剩余块,结果展开一步步对比以后动作序列几乎一致。原文是状态演进图(A1~B5),可以抽成下面这个过程:
初始: A1 A2 A3 B1 B2 B3 B4 B5
swap(first, mid) 持续推进后:
B1 B2 B3 A1 A2 A3 B4 B5
继续交换对应位置:
B1 B2 B3 B4 B5 A3 A1 A2
最后对剩余 A3 / A1 A2 子块继续同样逻辑
推测代码(示意):
while (first != mid && mid != last) {
std::iter_swap(first++, mid++);
// first 到达原 A 尾部后,等价于对子问题继续 rotate
}
结论就是:同一套动作,不同叙事角度。
这篇讲 clang/libc++ 的 rotate,是另一条路线:把 rotate 看成一个 permutation,然后按 cycle decomposition 去搬元素。
如果左块大小是 a,总长是 n,cycle 数量就是 gcd(a, n)。每个 cycle 都是“当前位置加 a,越界就 wrap”,最后回到起点。实现方式就是存一个临时值,然后沿 cycle 一路搬过去:
auto a = std::distance(first, mid);
auto n = std::distance(first, last);
auto g = gcd(a, n);
for (auto k = 0; k < g; ++k) {
auto save = std::move(first[k]);
auto i = k, next = k;
while ((next = (i + a) % n) != k) {
first[i] = std::move(first[next]);
i = next;
}
first[i] = std::move(save);
}
好处是 swap 数量最少,大约 n / 2 次 swap 等价成本,非常漂亮。坏处也明显:locality 很烂,因为同一个 cycle 里的元素可能在数组里离得很远。所以这是那种理论上很优雅、缓存未必高兴的算法。
接上一条,这篇说的是:cycle decomposition 不一定非得先算 gcd。
OpenJDK 那种“靠计数收敛”的写法:
auto a = std::distance(first, mid);
auto n = std::distance(first, last);
auto count = 0;
auto k = 0;
while (count < n) {
auto save = std::move(first[k]);
auto i = k, next = k;
while ((next = (i + a) % n) != k) {
first[i] = std::move(first[next]);
i = next;
++count;
}
first[i] = std::move(save);
++count;
++k; // 评论区抓出的关键一行
}
技巧来自 OpenJDK:既然总元素数等于所有 cycle 长度之和,而每个新起点都会落在一个新 cycle 上,那就维护一个 count,每搬一个元素就加一,直到 count == n 为止。这样可以省掉显式 gcd 计算。
当然文里代码一开始还漏了个 ++k,评论区当场抓包。Raymond 的博客就这点好,连小 bug 都是节目效果的一部分。
这是系列收尾,聊了几个零碎点。第一个是把“旋转三块”推广成“常数空间下重排 k 块”:每次把应该去最前面的块 rotate 到前面,剩下部分递归处理,最多 kn 次 swap 量级。
原文核心公式可以直接摘:
Given k blocks, of total size n,
you can shuffle them arbitrarily in at most kn swaps in constant space:
Take the block that goes first and rotate it to the front, which takes n swaps.
Then recurse on what’s left.
推测代码(示意):
template<class It>
void shuffle_k_blocks(It first, std::span<Block> blocks) {
if (blocks.size() <= 1) return;
auto b0 = pick_block_that_goes_first(blocks);
std::rotate(first, b0.begin, b0.end);
shuffle_k_blocks(first + b0.size, blocks.subspan(1));
}
后面还顺手吐槽了 “XOR swap 一把梭” 这种评论区祖传绝活。作者说得很实在:你拿 XOR 去换字符串、Widget、内部有指针的对象试试?不如老老实实 move/swap。 另外还澄清了一下“constant space”并不是“零空间”,只是额外空间是常数,不需要动态分配。这个系列整体挺适合拿来当 rotate 的八卦读物。
这篇讲两个跟 string literal 有关的修补提案:P2361R6 和 P1854R4。别看不显眼,都是在收拾祖传破窗。
P2361R6 引入了 unevaluated string 的明确概念,像 static_assert 消息、_Pragma、attribute 里的字符串,因为本来就不会进运行时,所以不该再假装自己有执行编码那套语义。结果就是:这些场景不允许 encoding prefix,很多 numeric escape 也非法了。
这种写法在新规则下就是问题代码:
static_assert(false, L"bad");
static_assert(false, u8"bad");
P1854R4 则更狠:如果 evaluated string literal 里有字符没法表示成目标编码,那程序直接 ill-formed,不再允许编译器悄悄给你替成 ?。
也就是类似下面这种,以前某些编译器可能“帮你糊弄过去”,以后该报错就报错:
const char* greeting = "こんにちは";
const char8_t* greeting8 = u8"こんにちは";
这其实是好事,不然源码看着对,二进制里全烂了。都是不 flashy 的修补,但很实用。
这篇是一个预告,我吐了,目标是做一个 trait / concept:检查传进来的 callable 参数类型是不是“合理”,比如排序谓词别把大对象按值拷来拷去。
问题代码和目标非常明确:
template<typename ElementType, template<typename> typename ContainerTemplate, typename PredicateType>
void SortWrapper(ContainerTemplate<ElementType>& InContainer, PredicateType&& InPred)
{
static_assert(HasValidFunctorParams<PredicateType, ElementType, ElementType>);
std::ranges::sort(InContainer, InPred);
}
希望这个通过(按引用):
SortWrapper(strs, [](const std::string& a, const std::string& b) {
return a.size() < b.size();
});
这个失败(按值拷贝):
SortWrapper(strs, [](const std::string a, const std::string b) {
return a.size() < b.size();
});
作者给的动机非常实际。SortWrapper 这类泛型算法,传 [](const std::string&, const std::string&) 没问题,传 [](std::string, std::string) 就可能暗搓搓复制大对象。于是就想在编译期检查 callable 的参数签名。
后续系列会分别用 C++20 元编程和 C++26 reflection 来实现。
这篇最有意思的地方,不是“JVM 有多快”,而是 C++26 reflection 到底能把编译器推到什么程度。
它做的事其实很简单:把 Java bytecode 当成编译期数据,再把每条指令 lift 成一个已经特化好的 execute<Op, Arg...>。一旦 bytecode 被反射成常量,std::meta::substitute 就能把模板参数灌进去,[:spec:] 再把生成物塞回代码里。这样 runtime 的 switch(opcode)、pc++、循环回边这些东西,都会被编译器看成可折叠的静态结构,最后直接蒸发掉。
一句话概括:反射不只是“读类型信息”,而是把“数据驱动的解释器”改造成“编译器能直接看穿的程序生成器”。
仓库里有一条很清晰的演进路线,先从固定宽度 opcode 开始:
// experiments/01-fixed-width-opcodes/main.cpp
enum Op : uint8_t {
PUSH = 0x01,
ADD = 0x02,
SUB = 0x03,
HALT = 0xFF
};
constexpr std::array<uint8_t, 8> program {
PUSH, 5,
PUSH, 3,
ADD, 0,
HALT, 0
};
template<Op op, uint8_t arg = 0>
void execute(CPU& cpu) {
if constexpr (op == PUSH) {
cpu.stack[cpu.sp++] = arg;
} else if constexpr (op == ADD) {
auto a = cpu.stack[--cpu.sp];
auto b = cpu.stack[--cpu.sp];
cpu.stack[cpu.sp++] = a + b;
} else if constexpr (op == SUB) {
auto a = cpu.stack[--cpu.sp];
auto b = cpu.stack[--cpu.sp];
cpu.stack[cpu.sp++] = a - b;
}
}
consteval auto createProgram() {
constexpr auto tmpl = ^^execute;
std::array<void(*)(CPU&), 4> steps{};
using namespace std::meta;
template for (constexpr int idx : std::views::iota(decltype(steps.size()){0}, steps.size())) {
constexpr Op op = static_cast<Op>(program[2 * idx]);
constexpr uint8_t arg = program[2 * idx + 1];
constexpr auto spec = substitute(tmpl, {reflect_constant(op), reflect_constant(arg)});
steps[idx] = [:spec:];
}
return steps;
}
这里已经能看到反射的第一个威力:bytecode 不再是运行时要解释的数组,而是编译期就能“定型”的模板参数集合。 这时候 execute 还像个解释器,但它已经是“被编译器展开过一遍的解释器”了。
然后是更像真正 JVM 的 bytecode 解释器,开始有 CPU、getOpInfo 和 createOpcodeOffsets 了:
// experiments/02-jvm-bytecodes/main.cpp
struct CPU {
std::array<int32_t, 256> stack{};
std::array<int32_t, 256> locals{};
uint8_t sp{0};
int32_t result{0};
};
consteval int getOpInfo(Op op) {
switch (op) {
case BIPUSH: return 1;
default: return 0;
}
}
void execute(CPU& cpu) {
if constexpr (op == ICONST_M1) cpu.stack[cpu.sp++] = -1;
else if constexpr (op == ICONST_0) cpu.stack[cpu.sp++] = 0;
else if constexpr (op == ICONST_1) cpu.stack[cpu.sp++] = 1;
else if constexpr (op == ICONST_2) cpu.stack[cpu.sp++] = 2;
else if constexpr (op == ICONST_3) cpu.stack[cpu.sp++] = 3;
else if constexpr (op == ICONST_4) cpu.stack[cpu.sp++] = 4;
else if constexpr (op == ICONST_5) cpu.stack[cpu.sp++] = 5;
else if constexpr (op == BIPUSH) cpu.stack[cpu.sp++] = static_cast<int8_t>(arg);
}
consteval auto createProgram() {
constexpr auto tmpl = ^^execute;
constexpr auto offsets = define_static_array(createOpcodeOffsets());
std::array<void(*)(CPU&), offsets.size()> steps{};
size_t pc = 0;
template for (constexpr int offset : offsets) {
constexpr Op op = static_cast<Op>(program[offset]);
constexpr uint8_t arg = getOpInfo(op) == 1 ? program[offset + 1] : 0;
constexpr auto spec = substitute(tmpl, {reflect_constant(op), reflect_constant(arg)});
steps[pc] = [:spec:];
++pc;
}
return steps;
}
这一步开始就很像正经的 VM 了,但关键变化还是同一个:offset 也被 lift 了。 也就是说,连“下一条指令在哪”都变成编译期可见的事实。只要控制流是静态可推的,reflection 就会把它整个搬进编译期。
再往后,for-loop 版本就更直接了:
// experiments/03-jvm-forloop/main.cpp
consteval auto createProgram() {
constexpr auto tmpl = ^^execute;
constexpr auto steps = define_static_array(byteToStep());
std::array<void(*)(CPU&), steps.size()> program{};
size_t pc = 0;
template for (constexpr auto step : steps) {
constexpr auto spec = substitute(tmpl, {reflect_constant(step.op), reflect_constant(step.arg)});
program[pc++] = [:spec:];
}
return program;
}
void run(CPU& cpu, auto const& steps) {
while (cpu.running && cpu.pc < steps.size()) {
steps[cpu.pc++](cpu);
}
}
这就是反射真正吓人的地方:不是少写几行 switch,而是把解释器从“运行时控制流”变成“编译期生成的静态控制流”。 一旦 run() 里的状态推进也能被看穿,编译器就能继续做内联、常量传播、死代码删除,最后把原本像 VM 的东西压成一串普通机器码。
最后再进到 for-loop with templates,直接把 if_icmplt / iinc / goto 变成控制流模板:
// experiments/04-forloop-with-templates/main.cpp
template<Op op, int32_t arg = 0, int32_t arg2 = 0>
void execute(CPU& cpu) {
if constexpr (op == ILOAD_0) cpu.stack[cpu.sp++] = cpu.locals[0];
else if constexpr (op == ILOAD_1) cpu.stack[cpu.sp++] = cpu.locals[1];
else if constexpr (op == ILOAD_2) cpu.stack[cpu.sp++] = cpu.locals[2];
else if constexpr (op == ILOAD_3) cpu.stack[cpu.sp++] = cpu.locals[3];
else if constexpr (op == ISTORE_0) cpu.locals[0] = cpu.stack[--cpu.sp];
else if constexpr (op == ISTORE_1) cpu.locals[1] = cpu.stack[--cpu.sp];
else if constexpr (op == ISTORE_2) cpu.locals[2] = cpu.stack[--cpu.sp];
else if constexpr (op == ISTORE_3) cpu.locals[3] = cpu.stack[--cpu.sp];
else if constexpr (op == IADD) {
auto a = cpu.stack[--cpu.sp];
auto b = cpu.stack[--cpu.sp];
cpu.stack[cpu.sp++] = a + b;
}
else if constexpr (op == ISUB) {
auto a = cpu.stack[--cpu.sp];
auto b = cpu.stack[--cpu.sp];
cpu.stack[cpu.sp++] = b - a;
}
else if constexpr (op == IMUL) {
auto a = cpu.stack[--cpu.sp];
auto b = cpu.stack[--cpu.sp];
cpu.stack[cpu.sp++] = a * b;
}
else if constexpr (op == IINC) {
cpu.locals[arg] += arg2;
}
else if constexpr (op == IRETURN) {
cpu.result = cpu.stack[--cpu.sp];
cpu.running = false;
}
}
template<auto... Steps>
void block(CPU& cpu) { (Steps(cpu), ...); }
template<auto... Setup>
bool icmplt(CPU& cpu) {
(Setup(cpu), ...);
auto val2 = cpu.stack[--cpu.sp];
auto val1 = cpu.stack[--cpu.sp];
return val1 < val2;
}
template<auto Cond, auto Body>
void loop(CPU& cpu) {
while (Cond(cpu)) {
Body(cpu);
}
}
到这里就很清楚了:reflection 不是单点魔法,而是一路把“字节码、偏移、分支、循环”都往编译期搬。 你平时看 VM 以为它在解释程序,实际上这套实现的目标是让编译器替你解释,顺手再把解释结果优化没了。
文中 CPU 结构和核心 API 大概是:
struct CPU {
std::array<int32_t, 256> stack{};
std::array<int32_t, 256> locals{};
uint8_t sp{0};
int32_t result{0};
bool running{true};
};
// 反射 + substitute + splice 的核心链路
// 1) ^^execute
// 2) reflect_constant(opcode/args)
// 3) std::meta::substitute(...)
// 4) [:spec:]
这篇真正想强调的是 std::meta::substitute 的威力。平时大家看反射老是“遍历字段做序列化”,但这玩意往前一步就是把 reflection 当成编译期代码生成 substrate。DSL、解释器、语言桥接,全都能玩。
这篇是 hazard pointer 入门文,围绕 Michael & Scott queue 展开。重点不是 lock-free queue 本身,而是那个更恶心的问题:节点什么时候能删?
先看错误示例:
Node* old = head.load(std::memory_order_acquire);
head.compare_exchange_strong(old, old->next);
delete old; // unsafe
再看 hazard pointer 表:
static constexpr size_t NUM_THREADS = 8;
static constexpr size_t HP_PER_THREAD = 8;
static constexpr size_t RETIRED_THRESHOLD = 32;
std::atomic<void*> hazard_pointers[NUM_THREADS * HP_PER_THREAD];
保护协议核心:
template <typename T>
T* protect(const std::atomic<T*>& src) {
T* ptr;
do {
ptr = src.load(std::memory_order_acquire);
hazard_pointers[hp_index].store(ptr, std::memory_order_release);
} while (ptr != src.load(std::memory_order_acquire));
return ptr;
}
回收扫描:
for (auto it = retired.begin(); it != retired.end(); ) {
if (std::find(hp.begin(), hp.end(), it->ptr) == hp.end()) {
it->deleter(it->ptr);
it = retired.erase(it);
} else {
++it;
}
}
作者先拿一个经典错误示范开刀:CAS 把 head 挪走以后立刻 delete old。单线程看着没毛病,多线程下另一个线程可能还拿着 old,直接 use-after-free。于是就引出 hazard pointer 协议:线程先声明“这个指针我可能要 dereference,先别回收”。
实现也挺标准:全局 hazard pointer 表、线程局部 retired list、RAII guard 负责 load -> publish -> recheck,retired 数量到阈值后扫表回收。作者点得很好:hazard pointers 不是数据结构,是建立在内存模型上的协议。小地方写错了,bug 就又阴又难复现。适合入门理解。
这篇 benchmark 很打脸。作者比较 std::variant + std::visit 和 virtual dispatch,结果发现:同样代码、同样机器、同样 flags,GCC 11 上 visit 比 virtual 慢 28%,GCC 12 起直接反杀,快 40% 左右。
原因不是抽象变了,而是 libstdc++ 12 给 std::visit 加了 small variant 的 switch fast path。旧版本更像这样:
movzbl 23(%rsp), %eax
...
call *(%r15,%rax,8) ; 函数指针表间接调用
GCC 12 后会被优化成“switch + 外提 + 内联”风格:
switch (__v.index()) {
case 0: return __visitor(std::get<0>(__v));
case 1: return __visitor(std::get<1>(__v));
case 2: return __visitor(std::get<2>(__v));
}
virtual 这边基本不变:
movq (%rdi), %rax
call *16(%rax) ; vtable entry 间接调用
结论也很实在:别轻易说“variant 比 virtual 快/慢”,先说清楚你用的是哪版编译器、哪套 stdlib。 benchmark 不带版本号,基本等于没说。这个结论拿去打很多“抽象 A 永远优于抽象 B”的文章都够用了。
这篇真正有价值的是:它不是“哈希表换个实现”这种口号文,而是把 workload 先量化,再反推数据结构选择。结论是 LLVM 这类编译器工作负载更适合 linear probing + Algorithm R deletion,而不是继续堆 tombstone,或者盲目上 Robin Hood / Swiss Table。
分析过程:先测 workload,再做算法取舍
作者先 instrument clang,在真实编译中统计 DenseMap / DenseSet 操作分布(llvm/lib/Analysis/ScalarEvolution.cpp):
find-hit ~65.2M
find-miss ~65.7M
insert ~47.8M
erase ~7.0M
grow ~1.5M
关键观察:
DenseMap<const void *, Pass *>)。简单说:LLVM 的痛点不是“miss 太离谱”,而是高频操作的常数项和删除后表状态退化。
为什么先砍 tombstone
tombstone 的问题不是功能错误,而是长期运行下会把 probe chain 污染。删得越多,后续 insert/find 的路径越脏,开销会累计。Algorithm R 的策略是删除时就把洞补平,保持 probe chain 连续:
unsigned j = i;
while (true) {
j = (j + 1) & mask;
if (!used(j)) break;
unsigned home = hash(buckets[j].key) & mask;
if (((i - home) & mask) < ((j - home) & mask)) {
buckets[i] = std::move(buckets[j]);
i = j;
}
}
unsetUsed(i);
这等于把“删除成本”从未来每次查询里提前支付一部分,换稳定的后续查找/插入路径。对 LLVM 这种长流程、反复 mutate 的编译过程,这个交换是划算的。
为什么不选 Robin Hood
Robin Hood 主要收益在高负载下的 find-miss,但它通常会拉高 find-hit 和 insert 成本。LLVM 的实际分布是 hit/miss 都多、insert 也不低、且平均 probe 并不高,所以它并不需要为了省 miss 的几个 probe,引入更重的插入/位移逻辑。
作者原文还点了一个现实问题:要发挥 Robin Hood 的早停优势,通常要维护 displacement 元数据或做更多重哈希检查,这会把常数项和实现复杂度抬高。LLVM 场景里,这笔账不合适。
为什么不选 Swiss Table 家族
不是 Swiss Table 不强,而是 LLVM 约束不匹配:
原文结论很直接:这些替代方案都做过原型和测量,最终在 LLVM workload 上没有形成更优的综合结果。
DenseMap 的第二步:去掉 empty sentinel,改 used-bit array
这一刀也不是“理论正确”,而是数据驱动 trade-off:
find-miss / iteration / large inserts win
find-hit / small inserts lose
也就是 miss、遍历、大表插入更好;hit 和小表插入有些成本。综合下来整体收益依旧为正,并且顺手解决了 integer key 的历史坑(-1/-2 sentinel 约束)。
结果与点评:
SmallPtrSet 先做了风险前置验证,DenseMap、StringMap 再跟进,节奏很稳。0.x% 的全局改进看着小,但这是全链路真收益。性能上,整个系列在 LLVM compile-time tracker 上大概是 0.35% 到 0.47% 指令数下降,局部像 DenseMap/SmallPtrSet/StringMap 相关头文件的 self-instructions 能降得更多。文章还顺手把 Robin Hood、Swiss Table 家族为什么不适合 LLVM 这种 workload 也分析了一遍
微软和 Adobe 一起给 Photoshop 做性能优化,核心方案是:在 /GL + LTCG` 的 peak-performance build 基础上,再叠 Sample-based PGO,也就是 SPGO。
文章里说得很明确,传统 instrumentation-based PGO 在 Photoshop 这种超大工程里不好落地:要单独构建插桩版本,跑得慢,还容易因为环境不对导致 profile 不像真实用户负载。SPGO 则直接拿 release binary 的 sample profile 来喂优化器,工程成本低很多。
这里原文没有给完整命令行脚本,主要强调的是链路:先用 /GL + LTCG 做 peak-performance build,再用 SPGO 读取真实负载采样数据做 profile-driven 优化。 我这里不再放示意命令,避免把文中没给出的流程细节写死。
文中/评论里能直接引用的命令与日志要点:
/GL
/SPGO
SPD ... not found, compiling without profile guided optimizations
推测代码(示意流程):
cl /O2 /GL /c *.cpp
link /LTCG /SPGO *.obj /OUT:app.exe
结果也挺能打:Adobe 报的数据是 x64 提升 20%、ARM64 提升 13%,体现在画笔/描边响应、文件打开、部分 filter 等用户最容易感知的 CPU 场景。
这篇介绍的是一个叫 recorder 的小工具,作者把它叫 C/C++ 程序的“飞行记录仪”。理念非常朴素:别猜程序在干什么,直接打低成本 probe,然后实时画图。
用法也很直接,声明一个 RECORDER(name, size, desc),然后像 printf 一样 record(name, fmt, ...):
RECORDER(gc_stats, 16, "Garbage collection statistics");
record(gc_stats,
"GC has %u bytes in %u objects after purging %u bytes from %u objects in %u ms",
available(), objcount - delcount, recycled, delcount, duration);
RECORDER(available, 16, "Available memory and requests");
record(available, "Available %u for %u", avail, size);
作者拿自己那个内存极其紧张的计算器项目 DB48x 做 demo,在只给 50K 内存的情况下实时看 GC 和 allocation 行为,图上直接能看到 bytes、objects、purged_bytes、duration 这些曲线。
比较有意思的是,这套 instrumentation 号称便宜到可以常驻生产环境,默认不激活,需要时靠命令行或环境变量打开。还能把数据写进共享内存,再用单独的 recorder_scope 进程拉出来实时画图。感觉就是把 printf 调试、flight recorder 和轻量级 tracing 糅一块。对嵌入式/系统程序挺有吸引力。
这篇很适合拿来当“C++26 reflection 能立刻做什么”的展示:写一个几乎通用的 formatter / printer,把成员名、成员值、基类成员、枚举名字全打印出来。
文章一路从 ^^T、nonstatic_data_members_of、bases_of、identifier_of、splice [:member:] 这些基本操作讲起,先把普通 struct 打出来:
template for (constexpr auto member : std::define_static_array(
nonstatic_data_members_of(^^Coordinate, ctx))) {
std::println("{} : {}", identifier_of(member), co.[:member:]);
}
再拼成通用 formatter 的核心:
template<typename T>
auto format(const T& t, auto& ctx) const {
auto out = ctx.out();
std::format_to(out, "{}{{", identifier_of(^^T));
template for (constexpr auto base : std::define_static_array(bases_of(^^T, access_ctx))) {
std::format_to(out, "{}", (typename [: type_of(base) :] const&)(t));
}
template for (constexpr auto member : std::define_static_array(nonstatic_data_members_of(^^T, access_ctx))) {
std::format_to(out, ".{} = {}", identifier_of(member), t.[:member:]);
}
std::format_to(out, "}}");
return out;
}
核心观感就一句:以前这种活要靠宏、Boost.PFR、手写访问器、奇怪 trait,现在反射直接把“遍历字段”这件事正名了。 代码不一定已经到了工业可用程度,但方向非常清晰。等实现成熟了,这类 boilerplate 估计真要死一大片。
这是一个非常典型的问题:硬件回调线程要求快速返回,否则设备缓冲就爆了;于是你把活丢给 thread pool,结果 thread pool 偶尔 100ms 才调度,业务要求却是 20ms 内处理完。
Raymond 的回答也很实在:线程池是为 throughput 设计的,不是为 latency 设计的。没有 deadline 参数能救你。 如果默认线程池上有别的长任务、杂活,你这边就得排队。
解决办法有两个思路。一个是建 private thread pool,甚至把最大线程数设成 1,等价于一个串行 worker,只是 bookkeeping 交给系统。另一个更直接:别拐弯,自己起 worker thread + queue + condition_variable。
文里的 C# worker 示例:
Queue<Data> queue = new Queue<Data>();
Data WaitForWork() {
while (true) {
lock (queue) {
if (queue.Count > 0) return queue.Dequeue();
Monitor.Wait(queue);
}
}
}
void WorkerThread() {
Data data;
while ((data = WaitForWork()) != null) {
// process
}
}
如果顺序很重要,本来也不该把它当完全自由并发的 thread pool work item 来看。说白了,线程池不是银弹,低延迟场景常常还是专线程靠谱。
这是 Sandor Dargo 对 Patrice Roy 那本《Memory Management in C++》的书评。挑了几个点讲,都挺基础但挺重要。
第一个是 char* 实际上更像“byte pointer”,这来自 C 的历史包袱,也会影响 alias analysis 和优化。现代 C++ 想表达“原始字节缓冲区”,更合适的是 std::byte*:
std::is_object_v<int*> // true
std::is_object_v<int&> // false
推测代码(示意):
void process_bytes(std::span<std::byte> data);
// 老代码常见写法:void process(char* p, size_t n);
第二个点是指针是对象,引用不是对象,这类平时不细想、真到内存布局和 lifetime 分析时又躲不开的基本功:
第三个点则是老生常谈但永远有效:优化前先测,升级编译器以后再测,别把自己的手工优化当圣经。
书评本身不是技术深挖,更像是提醒你“内存管理这门课值得系统补一遍”。如果你平时都靠经验混过去,这本书可以看看。
这篇说的是 Brno 会议里一个不太像“语言特性”的东西:UB 和 IFNDR 的 Annex。
作者觉得这可能是 C++29 最有价值的改动之一,因为它不是再塞个花哨语法,而是第一次系统化地把标准里各种显式 UB 和 IFNDR 统一列出来、命名、加示例、加交叉引用。这玩意现在作为文档就已经有用了,编译器/工具链后面还能逐步把这些名字拿去做 diagnostics。
配套还有 P3100,想系统性地处理 UB。短期内当然不可能把 UB 全修完,但至少过程开始正规化了:先列清单,再讨论哪些能消掉、哪些能缩小范围。安全这几年越来越热,这个方向显然会越走越远。这个 annex 真不是花里胡哨用处不大,反而非常务实。
原文重点是“统一命名 + 统一索引 + 交叉引用”,不靠某个单例代码片段来说明,因此这里也不硬加示例代码,避免把 Annex 的价值讲窄了。以前很多团队是“知道有 UB,但不知道具体有哪些类别、叫什么、去哪查”;Annex 出来后,文档、诊断、工具链可以逐步对齐同一套词汇。
这篇原文里最硬的信息是提案编号:
P3596 (Undefined Behavior and IFNDR Annexes)
P3100R6 (A framework for systematically addressing undefined behaviour)
P1705R1 (早期列表工作)
推测代码(示意一个会被 Annex 覆盖的 UB 类别):
int x = 1;
int y = x << 31; // 典型未定义行为类别之一(依实现与标准条款)
Daniel Lemire 这篇也很秀:如果配置 JSON 在构建时就固定,干嘛还运行时打开文件、解析、报错?直接编译期读进来、解析掉、烘进二进制。
示例 JSON:
{ "width": 1920, "height": 1080, "fullscreen": true,
"title": "My Game", "volume": 0.8 }
用 #embed 把文件字节编进程序:
constexpr const char json_data[] = {
#embed "data.json"
, 0
};
再用 static reflection 版 simdjson 编译期解析:
#define SIMDJSON_STATIC_REFLECTION 1
#include "simdjson.h"
constexpr auto window = simdjson::compile_time::parse_json<json_data>();
static_assert(window.width == 1920);
static_assert(window.height == 1080);
static_assert(window.fullscreen == true);
后续函数甚至会被编译器直接折叠成常量:
int screen_area() { return window.width * window.height; }
方法靠两个东西:#embed 和静态反射版 simdjson。#embed 能在编译期把 data.json 变成字节数组,simdjson::compile_time::parse_json<json_data>() 则在编译期把它解析成一个对象,甚至能根据 key 合成 struct 形状。于是你可以直接 static_assert(window.width == 1920);,JSON 写坏了就在编译时报错。
更狠的是,后续用这个配置做计算,比如 window.width * window.height,在 -O3 下最终二进制里甚至只剩立即数。JSON 和 parser 都消失了。 这类玩法很适合固定配置、资源嵌入、静态地图数据之类场景。抽象程度很高,但确实牛逼。