| layout | post |
|---|---|
| title | 第195期 |
公众号
点击「查看原文」跳转到 GitHub 上对应文件,链接就可以点击了
qq群 753792291 答疑在这里
欢迎投稿,推荐或自荐文章/软件/资源等,评论区留言
https://cppstat.dev/ 分享c++进展的网站
Raymond Chen讲怎么在C#里实现类似C++的scope_exit功能
C#的try-finally有个问题:清理代码离资源创建太远,代码审查时容易漏掉。而且多层嵌套的try-finally简直不忍直视:
var gadget = widget.GetActiveGadget(Connection.Secondary);
if (gadget != null) {
try {
⟦ lots of code ⟧
if (gadget.IsEnabled()) {
try {
⟦ lots more code ⟧
} finally {
gadget.Disable();
}
}
} finally {
widget.SetActiveGadget(Connection.Secondary, null);
}
}解决方案 - ScopeExit类:
.NET 8引入了using语句和ref struct,可以这么玩:
public ref struct ScopeExit
{
public ScopeExit(Action action)
{
this.action = action;
}
public void Dispose()
{
action.Invoke();
}
Action action;
}用起来就清爽多了:
var gadget = widget.GetActiveGadget();
if (gadget != null) {
using var clearActiveGadget = new ScopeExit(() => widget.SetActiveGadget(null));
⟦ lots of code ⟧
if (gadget.IsEnabled()) {
using var disableGadget = new ScopeExit(() => gadget.Disable());
⟦ lots more code ⟧
}
}清理逻辑就近放置,不用深层嵌套,代码结构清晰多了
技术改进(评论区讨论):
- 用
Interlocked.Exchange(ref action, null)?.Invoke()代替简单的action.Invoke(),确保Dispose只执行一次 - C# 13允许ref struct实现接口,可以显式实现IDisposable
- 异常安全性问题:lambda的delegate分配可能失败(OutOfMemoryException),这会在设置scope-exit之前引入异常窗口
不如C++ WIL的scope_exit提供的保证严格,但实践中已经够用了。ReactiveExtensions库(Rx.NET)也提供了完全相同的helper功能
GPU基准测试遇到的大坑:动态频率调整
作者的RTX 2080空闲时GPU跑300MHz(显存100MHz),负载下应该是1650-1815MHz(显存1937MHz)。这差异达到5-6倍(GPU)和19倍(显存)!场景渲染时间在2ms、4ms、6ms之间不稳定跳动,根本没法做性能测试
常见解决方案:
- SetStablePowerState.exe:用DX12 API的
ID3D12Device::SetStablePowerState固定频率。简单,但容易忘记关闭 - nvidia-smi命令行工具:Nvidia推荐的新方法,但更麻烦,需要手动重置
作者的解决方案 - gpu_stable_power库:
在Vulkan应用中创建DX12设备上下文来调用SetStablePowerState:
#include <gpu_stable_power/gpu_stable_power.h>
int main()
{
// Defaults to off
gpu_stable_power::Context stable_power;
// Lock clock speeds
stable_power.set_enabled( true );
// Do benchmark
// Optional: manual toggle off
stable_power.set_enabled( false );
// Automatically disables itself on destruction
}用RAII模式,构造时启用,析构时自动禁用。跨平台兼容(非Windows平台自动变为no-op)。在Release构建中自动变为no-op。
库已开源:https://github.com/mropert/gpu_stable_power
GPU基准测试必须固定时钟频率,这是业界公认的做法。动态频率调整对测试结果影响太大了
WebGL性能暴跌的诡异案例:从60FPS掉到1-2FPS
作者在移植游戏"You Are Circle"到浏览器时遇到灾难性性能问题。添加可破坏岩石功能后,某些机器上帧率暴跌,背景音乐都卡了
场景规模:
- 约100个岩石
- 每个最多11个三角形(非常低多边形)
- 这点东西不应该卡啊
问题定位:
原始代码长这样:
for (auto& each : rock)
{
auto vertex_source = per_frame_buffer.transfer_vertex_data(each.vertex_data);
auto index_source = per_frame_buffer.transfer_index_data(each.index_data);
device.draw(vertex_source, index_source, ...);
}看起来没问题?实际上每次transfer_index_data都触发了隐藏的缓存操作:
for (auto& each : rock)
{
auto vertex_source = per_frame_buffer.transfer_vertex_data(each.vertex_data);
invalidate_per_frame_index_buffer_cache(); // 隐藏操作!
auto index_source = per_frame_buffer.transfer_index_data(each.index_data);
fill_cache_again_for_the_whole_big_index_buffer(); // 又是隐藏操作!
device.draw(vertex_source, index_source, ...);
}性能数据:
- 对于100个小岩石,循环每帧处理约400MB的额外数据
- 相当于每秒24GB!
- Firefox Gecko Profiler显示图形线程卡在
MaxForRange<>函数上
根本原因:
WebGL需要验证索引缓冲区有效性以防越界访问(安全原因)。浏览器用缓存机制避免重复验证。作者用4MB的per-frame buffer流式传输顶点和索引数据。问题是:
- 每次传输索引数据都使整个索引缓冲区缓存失效
- 缓存失效后需要重新扫描整个大缓冲区重建缓存
- 每个岩石都触发一次,100个岩石就是100次
为什么开发机器没发现?作者推测是因为开发机器的CPU有更大的L2/L3缓存,索引扫描很快
解决方案:
批处理(Batching)- 把所有岩石几何体合并成单个网格,用一次绘制调用搞定
进一步优化建议:
- 重新排序更新和绘制调用:所有buffer更新放在所有绘制调用之前
- 保持索引缓冲区尽可能小
"缓存是计算机科学中最难的两件事之一",这案例再次证明了这句话。看似简单的immediate mode渲染,在WebGL的安全验证机制下,因为缓存策略的交互,导致了灾难性的性能下降
Antithesis分享如何把单线程C++与多线程异步Rust对接的实战经验
背景:
- C++端:Fuzzer用单线程编写,通过回调接口与控制器交互
- Rust端:新的控制策略用多线程异步实现
- 核心矛盾:单线程同步C++ vs 多线程异步Rust
挑战1:线程不安全的对象
C++的State对象用非线程安全的引用计数(类似Rc),不能直接跨线程传:
struct State {
ref_ptr<StateImpl> impl; // 类似Rc,不是Arc
...
}直接标记unsafe impl Send for State {}会段错误!
第一个解决方案 - CppOwner/CppBorrower:
pub struct CppOwner<T> {
value: Arc<T>
}
impl<T> CppOwner<T> {
pub fn borrow(&self) -> CppBorrower<T> {
CppBorrower { value: self.value.clone() }
}
pub fn has_borrowers(&self) -> bool {
Arc::strong_count(&self.value) > 1
}
}
impl<T> Drop for CppOwner<T> {
fn drop(&mut self) {
if self.has_borrowers() {
panic!("No!"); // 还有借用者就panic
}
}
}
pub struct CppBorrower<T> {
value: Arc<T>
}
unsafe impl<T: Sync> Send for CppBorrower<T> {}主线程持有CppOwner,其他线程用CppBorrower。定期垃圾回收:
self.in_flight.retain(|s| s.has_borrowers());问题:垃圾回收效率低,工作量与对象数量成正比
更好的解决方案 - SendWrapper + DropQueue:
pub struct SendWrapper<T>(T);
unsafe impl<T> Send for SendWrapper<T> {} // 强制可Send
pub struct CppOwner<T> {
value: ManuallyDrop<SendWrapper<T>>,
}
impl<T> Drop for CppOwner<T> {
fn drop(&mut self) {
let value = unsafe { ManuallyDrop::take(&mut self.value) };
DROP_QUEUE.push(value); // 送回主线程销毁
}
}
static DROP_QUEUE: DropQueue = DropQueue::new();
pub struct DropQueue {
queue: Mutex<Vec<Box<dyn FnOnce()>>>,
}
impl DropQueue {
pub fn drain(&self, _token: MainThreadToken) {
let mut queue = self.queue.lock().unwrap();
for f in queue.drain(..) {
f(); // 在主线程执行销毁
}
}
}关键点:
- SendWrapper让非Send类型也能跨线程传
- 只暴露
&T(当T: Sync时),永不暴露&mut T - 通过DropQueue把对象送回主线程销毁
- 效率提升:工作量与删除数量成正比,不是对象总数
挑战2:线程不安全的函数
某些C++方法只能在主线程调用。如何在编译期保证?
MainThreadToken - 零开销的编译时保证:
pub struct MainThreadToken {
_marker: PhantomData<*mut ()>,
}
impl MainThreadToken {
/// Safety: Only call this on the main thread
pub unsafe fn new() -> Self {
assert!(std::thread::current().id() == MAIN_THREAD_ID);
Self { _marker: PhantomData }
}
}使用方式:
impl DropQueue {
pub fn drain(&self, _token: MainThreadToken) { // 需要token
// ...
}
}C++端的SYNC/UNSYNC标记:
#define SYNC /* marker for thread-safe const methods */
#define UNSYNC /* marker for thread-unsafe const methods */
class MyClass {
int get_immutable_data() const SYNC;
int get_mutable_data_unsync() const UNSYNC;
};Rust端对应的安全包装:
extern "C++" {
/// Safety: Only call on the main thread
unsafe fn get_mutable_data_unsync(&self) -> i32;
}
impl MyClass {
pub fn get_mutable_data(&self, _token: MainThreadToken) -> i32 {
unsafe { self.get_mutable_data_unsync() }
}
}核心哲学转变:
- C++风格:"非常小心地思考你在做什么"
- Rust风格:"利用编译器帮你发现问题"
最终方案通过类型系统和编译器强制执行安全性,而不是依赖程序员的小心谨慎。这才是Rusty的做法
该方案已从研究代码转向生产环境使用。为C++代码定义了明确的安全义务和规范,让团队其他成员也能安全使用
Clément GRÉGOIRE(性能与优化专家)的劝退文:为什么你不应该自己实现自旋锁
核心观点:在大多数情况下,你不应该使用自旋锁,应该使用OS提供的原语(如futex、WaitOnAddress)
文章列举了11种常见的自旋锁陷阱,每一个都能让你的锁变成灾难
问题1:破损的自旋锁(缺乏原子性)
class BrokenSpinLock
{
int32_t isLocked = 0;
public:
void lock()
{
while (isLocked != 0) {} // 检查
// 其他线程可能在这里插入!
isLocked = 1; // 设置
}
};竞态条件,两个线程可以同时获得锁。修复:
void lock()
{
while (isLocked.exchange(1) != 0) {} // 原子操作
}问题2:烧毁CPU
空转循环让CPU以最高频率运行,功耗爆炸。需要用PAUSE指令:
void cpu_pause()
{
#if defined(__x86_64__)
_mm_pause();
#elif defined(__aarch64__)
__yield();
#endif
}
void lock()
{
while (isLocked.exchange(1) != 0)
{
cpu_pause();
}
}问题3 & 4:等待时间
PAUSE指令的延迟在不同CPU上差异巨大:
- 老Intel: ~10周期,老AMD: ~3周期
- Skylake及更新Intel: 140-160周期
- 现代AMD Zen: ~60-65周期
**差异达10倍以上!**固定计数不行,需要指数退避+抖动+基于TSC周期:
struct Yielder
{
static const int maxPauses = 64;
int nbPauses = 1;
const int maxCycles = /*根据CPU测算*/;
void do_yield()
{
uint64_t beginTSC = __rdtsc();
uint64_t endTSC = beginTSC + maxCycles;
const int jitter = static_cast<int>(beginTSC & (nbPauses - 1));
const int nbPausesThisLoop = nbPauses - jitter;
for (int i = 0; i < nbPausesThisLoop && before(__rdtsc(), endTSC); i++)
cpu_pause();
nbPauses = nbPauses < maxPauses ? nbPauses * 2 : nbPauses;
}
};问题5:内存顺序
用SeqCst太重了,用Acquire/Release就够:
void lock()
{
while (isLocked.exchange(1, std::memory_order_acquire) != 0)
{
yield.do_yield();
}
}
void unlock()
{
isLocked.store(0, std::memory_order_release);
}性能对比:
| 锁类型 | 无竞争 (ops/s) | 有竞争 (ops/s) |
|---|---|---|
| SeqCst | 313M | 55.3M |
| AcqRel | 612M | 58.7M |
| Acquire | 652M | 65.3M |
问题6:Test-and-Test-and-Set
频繁的exchange会锁住缓存行。先用load检查再exchange:
void lock()
{
while (isLocked.exchange(1, std::memory_order_acquire) != 0)
{
do {
yield.do_yield();
} while (isLocked.load(std::memory_order_relaxed) != 0); // 先读
}
}问题7 & 8:优先级反转和唤醒风暴
高优先级线程空转,低优先级线程持锁但被抢占。yield()可能唤醒无关线程导致调度风暴
问题9:正确的解决方案 - 使用OS原语
void do_yield(int32_t* address, int32_t comparisonValue, uint32_t timeoutMs)
{
do_yield_expo_and_jitter();
if (nbPauses >= maxPauses)
{
// 等待地址值变化或被唤醒
WaitOnAddress(address, &comparisonValue, sizeof(comparisonValue), timeoutMs);
nbPauses = 1;
}
}
void lock()
{
while (isLocked.exchange(1, std::memory_order_acquire) != 0)
{
do {
yield.do_yield(&isLocked, 1, 1);
} while (isLocked.load(std::memory_order_relaxed) != 0);
}
}
void unlock()
{
isLocked = 0;
WakeByAddressSingle(&isLocked); // 通知等待的线程
}先自旋一会儿,退避到一定程度就调用OS原语休眠。两者结合
问题11:伪共享
alignas(std::hardware_destructive_interference_size) MyLock lock1;
alignas(std::hardware_destructive_interference_size) MyLock lock2;避免不同锁在同一缓存行
金句(Linus Torvalds):
"你永远不应该认为自己足够聪明到可以编写自己的锁机制...这真的很难。"
文章列举的错误实现案例:
- RPMalloc:只用单次CPU yield,实时调度器上会死锁
- OpenBSD libc:直接yield线程
- Intel TBB:固定计数退避
- Webkit:硬编码自旋计数
- AddressSanitizer:实时调度器上可能死锁
何时可以考虑自旋锁:
- 竞争程度极低
- 临界区非常小
- 已通知OS你的意图(使用futex/WaitOnAddress)
最佳建议:
- 优先使用OS锁原语(mutex、futex)
- 最好的锁是不使用锁 - 考虑无锁数据结构
- 如果真要自己实现,至少要做到上面提到的所有点
自旋锁看似简单,实则极其复杂
用Lambda立即调用来初始化const变量的技巧
你有没有遇到过这种情况:想让变量保持const,但初始化逻辑又得用if/else?传统做法是先声明成non-const,初始化完再假装它是const。IIFE让你能用多行代码初始化const变量
传统写法的问题:
int myVariable = 0; // 本应该是const...
if (bFirstCondition)
myVariable = bSecondCondition ? computeFunc(inputParam) : 0;
else
myVariable = inputParam * 2;
// 更多代码...
// 我们假装 myVariable 现在是constIIFE写法:
const int myVariable = [&] {
if (bFirstCondition)
return bSecondCondition ? computeFunc(inputParam) : 0;
else
return inputParam * 2;
}(); // 立即调用!
// 更多代码...基本语法就是:
const auto var = [&...] {
return /* 复杂逻辑 */;
}(); // 别忘了()调用实战案例 - HTML字符串构建:
传统写法:
std::string BuildStringTest(std::string link, std::string text) {
std::string html;
html = "<a href=\"" + link + "\">";
if (!text.empty())
html += text;
else
html += link;
html += "</a>";
return html;
}IIFE版本:
std::string BuildStringTestIIFE(std::string link, std::string text) {
const std::string html = [&link, &text] {
std::string out = "<a href=\"" + link + "\">";
if (!text.empty())
out += text;
else
out += link;
out += "</a>";
return out;
}();
return html;
}提高可读性:
末尾的()容易被忽略,可以用std::invoke:
const auto var = std::invoke([&] {
return /* 复杂逻辑 */;
});或者命名lambda:
auto initialiser = [&] {
return /* 复杂逻辑 */;
};
const auto var = initialiser();最佳实践:
- 优先显式捕获变量:
[&inputParam, &anotherValue]而不是[&],让依赖关系更明确 - 避免过长代码 - 如果lambda里塞了几十行,还是老老实实拆成函数
- C++26可以省略
()直接写[] noexcept {...}()
**性能:**基准测试显示编译器能很好地优化,IIFE版本有时反而更快10%左右
这个技巧挺实用的,特别是想让变量保持const但初始化逻辑又有点复杂的时候。虽然一开始看着有点怪,但用熟了会发现这种写法既保证了不可变性,又避免了为一小段逻辑单独创建函数的麻烦
Sandor Dargo的时钟系列第9部分:怎么测试带时间戳的代码
想象一下,你写了个函数,它会把数据连同当前时间一起存到数据库。问题来了:你怎么断言那个时间戳是对的?用时间范围?太脆弱了
问题代码:
struct Record {
int value;
std::chrono::system_clock::time_point created_at;
};
std::vector<Record> db;
void store(int value) {
db.emplace_back(value, std::chrono::system_clock::now()); // 失去控制
}一旦调用了now(),你就失去了控制权,测试就遭殃了
方案1:基于继承的时钟层次
struct Clock {
virtual ~Clock() = default;
virtual std::chrono::system_clock::time_point now() const = 0;
};
struct RealClock : Clock {
std::chrono::system_clock::time_point now() const override {
return std::chrono::system_clock::now();
}
};
struct FakeClock : Clock {
std::chrono::system_clock::time_point provided{};
std::chrono::system_clock::time_point now() const override {
return provided;
}
};
void store(int value, const Clock& clock) {
db.emplace_back(value, clock.now());
}经典OOP做法,明确易懂
方案2:使用C++20 Concepts
template <class C>
concept SystemClockLike = std::chrono::is_clock_v<C> && requires {
{ C::now() } -> std::same_as<std::chrono::system_clock::time_point>;
};
struct FakeClock {
using rep = std::chrono::system_clock::rep;
using period = std::chrono::system_clock::period;
using duration = std::chrono::system_clock::duration;
using time_point = std::chrono::system_clock::time_point;
static constexpr bool is_steady = false;
static inline time_point provided{};
static time_point now() { return provided; }
};
template<SystemClockLike Clock = std::chrono::system_clock>
void store(int value) {
db.emplace_back(value, Clock::now());
}零开销,编译期保证,适合模板密集的代码
方案3:Lambda时间提供器(最简单)
using TimeProvider = std::function<std::chrono::system_clock::time_point()>;
void store(int value, TimeProvider time_provider) {
db.emplace_back(value, time_provider());
}
// 测试
void testStore() {
auto expected_created_at = std::chrono::system_clock::now();
TimeProvider time_provider = [&expected_created_at]() {
return expected_created_at;
};
store(42, time_provider);
assert(db[0].value == 42);
assert(db[0].created_at == expected_created_at);
}连FakeClock都不需要了,最简单最直接
**作者建议:**从时间提供器方法开始。它最简单、可读性强,以后重构也容易。核心思想:不要硬编码时间来源!把时间当作可注入的依赖
测试带时间的代码就像测试随机数生成器 - 你得能控制住它才行。记住:把时间当朋友注入进来,别让它成为测试的敌人
Louis Baragwanath分析虚函数表性能:打破"虚函数慢"的常见误解
核心结论:虚函数的真正成本不在运行时的两次间接跳转,而在于阻止了编译器的跨函数优化
基础示例:
class Animal {
public:
virtual ~Animal() = default;
virtual void speak() const = 0;
};
class Dog : public Animal {
public:
void speak() const override { std::println("woof"); }
};
void make_noise(Animal** animals, int n) {
for (int i = 0; i < n; ++i) {
animals[i]->speak();
}
}编译后的汇编(-O3):
.L10:
mov rdi, [rbx] ; 加载 Animal*
mov rax, [rdi] ; 加载 vtable 指针
call [rax+16] ; 调用 speak()
add rbx, 8
dec rcx
jnz .L10两次间接加载:animals[i] → vtable → 函数指针
三个潜在性能问题分析:
1. CPU后端(依赖链)- 基本不是问题
现代CPU用乱序执行,将指令流解释为依赖图。vtable查找链与speak()函数的工作并行执行。除非函数极短或端口饱和,vtable开销会被有效隐藏
2. CPU前端(分支预测)- 表现良好
间接分支预测器(IBP)使用全局分支历史。控制流通常与数据相关:
Animal* a;
if (some_condition) {
a = new Dog();
} else {
a = new Cat();
}
a->speak(); // 预测器能学习这个模式在循环中,前几次迭代会建立历史,预测器能捕捉数组中派生类的相对顺序。分支误预测惩罚约15个周期
3. 编译器优化损失 - 真正的成本
虚函数调用目标不透明,编译器无法:
- 内联函数
- 常量折叠
- 常量传播
- 其他跨函数优化
优化是相互依赖的:不能内联就不能优化调用者
对象和vtable布局:
Dog: [vtable*][...Animal...][...Dog...]
Cat: [vtable*][...Animal...][...Cat...]
Dog vtable: [~Dog][Dog::speak]
Cat vtable: [~Cat][Cat::speak][Cat::other]
内存开销很小:只需几个缓存行存储vtable,会进入L1d和L1i缓存
优化建议:
-
给编译器尽可能多的信息
- 使用
const、noexcept、final - 简化控制流
- 启用LTO(链接时优化)
- 使用
-
如果数据真的随机,按派生类类型排序数组
- 预测器会快速学习当前目标
- 误预测只在类型切换时发生
-
重新设计极小的虚函数
- 如果虚函数体极简单,考虑是否真的需要多态
实际影响:
- L1缓存可以容纳vtable和指令
- 乱序执行能够隐藏vtable查找延迟
- 分支预测器在非随机数据上表现良好
只要虚函数不是极其简单的几条指令,而且数据不是完全随机的,vtable的性能影响几乎可以忽略。CPU层面的开销(额外加载、分支预测)在大多数实际场景中都会消失在噪音中
当然性能极致的场景还是需要devirtual优化的
用C++实现前向模式自动微分,整个核心实现就两个字段
三种求导方法对比:
- 符号微分:精确但只对公式有效,遇到控制流就废了
- 有限差分:简单但是近似值,每个导数方向都要额外求值一次
- 自动微分:在一次遍历中同时得到函数值和精确导数,支持控制流、循环、模板
核心数据结构简单到爆:
template <class T>
struct fwd_diff
{
T value;
T deriv;
};就这样!传播导数只需要对每个操作应用链式法则
乘法的实现(乘积法则):
template <class T>
fwd_diff<T> operator*(fwd_diff<T> const& f, fwd_diff<T> const& g)
{
return {
.value = f.value * g.value,
.deriv = f.deriv * g.value + f.value * g.deriv, // (f·g)' = f'·g + f·g'
};
}平方根:
template <class T>
fwd_diff<T> sqrt(fwd_diff<T> const& f)
{
auto sqrt_f = sqrt(f.value);
return {
.value = sqrt_f,
.deriv = f.deriv / (2 * sqrt_f), // (√f)' = f'/(2√f)
};
}工厂函数:
// x' = 1
static fwd_diff<T> input(T value)
{
return { .value = value, .deriv = T(1) };
}
// c' = 0
static fwd_diff<T> constant(T value)
{
return { .value = value, .deriv = T(0) };
}实战案例:样条路径的相机动画
camera_frame camera_frame_at(std::span<pos3f const> control_pts, float t)
{
using fdd = fwd_diff<float>;
// 用fwd_diff求值
auto p = camera_path(control_pts, fdd::input(t));
// 解包位置和切线
pos3f pos = pos3f(p.x.value, p.y.value, p.z.value);
vec3f forward = normalize(vec3f(p.x.deriv, p.y.deriv, p.z.deriv)); // 切线方向!
// 构建相机坐标系
vec3f world_up = {0, 1, 0};
vec3f left = normalize(cross(world_up, forward));
vec3f up = cross(forward, left);
return { .pos = pos, .left = left, .up = up, .forward = forward };
}只需一次求值就同时得到位置和切线方向。手动推导样条导数?不存在的
实战案例2:高度场法线
template <class HeightFn>
std::pair<float, vec3f> heightfield_with_normal_at(float x, float y, HeightFn&& h)
{
using fd32 = fwd_diff<float>;
// 对x求导
auto const hx = h(fd32::input(x), fd32::constant(y));
vec3f tx = {1.0f, 0.0f, hx.deriv}; // ∂/∂x
// 对y求导
auto const hy = h(fd32::constant(x), fd32::input(y));
vec3f ty = {0.0f, 1.0f, hy.deriv}; // ∂/∂y
// 法线是两个切线的叉积
auto const normal = normalize(cross(tx, ty));
return std::make_pair(hx.value, normal);
}两次传播就得到完整梯度,从精确梯度计算的法线比有限差分平滑多了
支持任意控制流:
auto foo(auto v)
{
if (v < 0)
return v * v;
else
return sqrt(v);
}
// 直接工作!
auto result = foo(fwd_diff<float>::input(x));甚至循环也没问题。导数会沿着实际执行的路径传播
性能优势:
- 只需一次函数求值(比有限差分少N次)
- 可以优化为SIMD(把deriv改成向量,一次计算多个方向)
- 零运行时开销(编译器能生成和手写导数一样高效的代码)
应用场景:
- 图形学:相机路径、地形法线、SDF表面提取
- 数值优化:梯度下降、Gauss-Newton、Levenberg-Marquardt
- 物理模拟:能量最小化、约束求解
- 小规模机器学习(少量参数的模型)
只要理解链式法则,实现就显而易见。作者第一次看到时觉得像魔法,但现在看来不过是"足够先进的技术"罢了。完整代码:https://godbolt.org/z/3eMecWPYx
PVS-Studio静态分析器发现的对齐相关陷阱
**基础概念:**对齐是数据在内存中按指定边界(2的幂)组织和访问。char对齐1字节,int对齐4字节,指针对齐8字节。编译器会自动插入padding字节满足对齐要求
问题1:结构体字段顺序导致空间浪费
struct Example
{
short int sh; // 2字节
char* ptr; // 8字节(需要8字节对齐)
char symbol; // 1字节
long ln; // 8字节(Clang)或4字节(MSVC)
};Clang:32字节(sh后6字节padding,symbol后7字节padding) MSVC:24字节(使用LLP64模型,long只有4字节)
优化后:
struct Example
{
char* ptr; // 8字节
long ln; // 8字节
short int sh; // 2字节
char symbol; // 1字节
// padding: 5字节
};Clang:24字节,MSVC:16字节。按对齐要求降序排列,减少padding
问题2:未对齐的指针转换(FreeCAD项目)
template <int N>
void TRational<N>::ConvertTo (double& rdValue) const
{
unsigned int auiResult[2]; // 4字节对齐
....
rdValue = *(double*)auiResult; // 错误!double需要8字节对齐
....
}auiResult按4字节对齐,强制转成double指针会导致未定义行为。现代CPU可能崩溃或性能下降
问题3:结构体比较包含padding(TDengine项目)
typedef struct STreeNode {
int32_t index;
void *pData;
} STreeNode;
int32_t tMergeTreeAdjust(SMultiwayMergeTreeInfo* pTree, int32_t idx) {
STreeNode kLeaf = pTree->pNode[idx];
if (memcmp(&kLeaf, &pTree->pNode[1], sizeof(kLeaf)) != 0) { // 错误!
....
}memcmp比较包括padding字节,而padding内容未定义。即使字段相同,padding不同也会导致比较失败
#pragma pack的陷阱:
#pragma pack(push, 1)
struct PackedStruct
{
char a;
int b;
double c;
};
#pragma pack(pop)强制1字节对齐,大小从16字节降到13字节。看起来省空间,实际:
- 未对齐访问导致性能下降(每次读取需要两次内存操作)
- 可能导致未定义行为
- SIMD指令要求对齐,违反会崩溃
PVS-Studio检测规则:
- V802:发现可以通过改变字段顺序减小结构体大小
- V1032:指针转换可能导致未对齐访问
- V1103:结构体字节比较中padding字节包含随机值
- V2666(MISRA):对齐说明符一致性
优化建议:
- 按对齐要求降序排列字段(大字段在前)
- 实例数量少时可以忽略优化,保持逻辑顺序
- 实例数量多(百万级)时优化是必要的
- 不要用memcmp比较包含padding的结构体
- 谨慎使用#pragma pack
Vinnie Falco:std::stop_token不仅是线程取消原语,而是通用的单次信号机制
核心观点:stop_token实现了经典的观察者模式(GoF 1994),但被"stop"这个名字掩盖了通用性
基本用法:
// 发布者
std::stop_source signal;
// 订阅者注册回调
std::stop_callback cb1(signal.get_token(), []{ initialize_subsystem_a(); });
std::stop_callback cb2(signal.get_token(), []{ initialize_subsystem_b(); });
// 触发信号(所有回调被调用)
signal.request_stop();问题1:命名误导
虽然叫"stop",但实际可以用来"启动":
std::stop_source ready_signal; // 实际是"准备好"信号
std::stop_callback worker1(ready_signal.get_token(), []{
initialize_subsystem_a();
});
ready_signal.request_stop(); // 名字说"停止",实际在"启动"问题2:单次限制
std::stop_source signal;
bool first = signal.request_stop(); // 返回true,回调被调用
bool second = signal.request_stop(); // 返回false,什么都不发生一旦信号过,就不能重置。这阻止了:
- 暂停/恢复
- 周期性通知
- 状态机
- 可重试操作
用例1:配置加载通知
std::stop_source config_ready;
std::stop_callback ui_cb(config_ready.get_token(), [&]{
apply_theme(config.theme);
});
std::stop_callback net_cb(config_ready.get_token(), [&]{
set_timeout(config.timeout);
});
config_ready.request_stop(); // 配置就绪,通知所有组件用例2:类型擦除的多态回调
std::stop_source event;
// 不同类型的callable共存
std::stop_callback cb1(event.get_token(), []{ /* lambda */ });
std::stop_callback cb2(event.get_token(), std::bind(&Foo::bar, &foo));
std::stop_callback cb3(event.get_token(), my_functor{});
// stop_source不知道具体类型,但能调用所有回调
event.request_stop();等价于std::vector<std::function<void()>>但更高效(无虚函数,栈上分配)
跨平台对比:
- Qt Signal-Slot(1991+):多次触发,类型安全
- Boost.Signals2:自动连接跟踪,线程安全
- .NET CancellationToken:单次信号,与stop_token最接近
- .NET ManualResetEvent:可重置,有
Set()和Reset()方法 - Chromium OneShotEvent:单次事件,与stop_token语义相同
.NET文档承认:"CancellationToken可以解决超出其原始范围的问题,包括应用程序运行状态订阅、使用不同触发器超时操作以及通过标志进行一般进程间通信。"
作者建议:
短期方案 - 类型别名:
namespace std {
using one_shot_signal_source = stop_source;
using one_shot_signal_token = stop_token;
template<class Callback>
using one_shot_signal_callback = stop_callback<Callback>;
}长期方案 - 可重置信号:
class signal_source {
public:
bool signal() noexcept; // 设置为已信号,调用回调
void reset() noexcept; // 回到未信号状态
bool is_signaled() const noexcept;
};stop_token是被严重低估的宝藏。它不是"仅用于取消的工具",而是成熟的观察者模式实现,只是被名字掩盖了通用性。理解这一点能解锁很多新用例
Roth Michaels分享在Native Instruments(音频软件公司)处理25年老代码库中的UB(未定义行为)的实战经验
核心观点:不要试图理解UB怎么工作的,just fix it
几个经典UB bug案例:
1. Xcode更新导致的UI变形
更新Xcode后,UI的梯形标签变成诡异的三角形,圆形按钮变成长椭圆。用cruse工具删代码找不到问题,最后发现是这段代码:
// 使用AG绘图库绘制椭圆
ag::ellipse e(x, y, radius);
ag::stroke stroke(e);
ag::conv_transform<ag::stroke> transform(stroke, get_transform()); // 临时对象!
rasterizer.add_path(transform);get_transform()返回临时对象,conv_transform构造函数存了指针,临时对象销毁后,rasterizer实际应用transform时读取悬空指针。编译器在UB情况下把x坐标传了两次(而不是x和y),所以椭圆变形了
修复:别用临时对象,在栈上存transform
2. Windows更新前一天的发布日崩溃
产品要发布,前一天Windows更新导致插件每次启动就崩。问题出在未初始化的snapshot_color:
struct SnapshotData {
std::array<float, 512> frequencies;
std::array<float, 512> decibels;
float opacity;
Color snapshot_color; // 没有默认值!
};序列化时调用了两次压缩来计算大小,两次读取的垃圾值不同,导致压缩后大小不一致,第二次写入时buffer overflow崩溃:
// 坑爹的序列化代码
auto data = get_state();
auto compressed_size = compress(data).size(); // 第一次压缩,读垃圾值A
auto buffer = allocate(compressed_size);
compress_into(data, buffer); // 第二次压缩,读垃圾值B,大小可能不同!Mac和旧Windows上碰巧两次读取的垃圾值导致相同压缩大小,新Windows不行了
修复:给snapshot_color加默认值
3. 非确定性DSP回归测试失败
测试有时红有时绿。用Address Sanitizer + UBSan发现内存对齐问题:
// 内存池分配临时buffer
auto analysis_buffer = pool.alloc<float>(512); // 512个float
auto calc_buffer = pool.alloc<double>(256); // 紧接着分配double当buffer大小不是2的幂时(如257个float),第二个double buffer的起始地址不是8字节对齐的,读写double时发生UB。Intel CPU能读不对齐地址,但C++标准不允许,Accelerate库和IPP库可能期望对齐
修复:内存池分配时保证类型对齐
4. std::sort的坑
// 错误:用了 <=
std::sort(v.begin(), v.end(), [](auto a, auto b) {
return a <= b; // 应该是 <,这不是严格弱序
});大部分情况能工作,但不符合sort的契约。新版Xcode或UBSan会报错
文化和工具变革:
文化层面:
- 恐吓战术 - 告诉所有人(包括QA和PM)UB有多可怕
- 重新优先级 - 闻起来像UB的bug提高优先级
- 教育全公司 - 给QA讲什么是"UB"(有人问"什么是UB?")
- 核心准则 - 添加规则"不允许编写UB",代码审查中不可讨论
工具层面:
- Clang-tidy - 只在新代码上运行
- Address Sanitizer (ASan) - CI中运行单元测试
- UB Sanitizer (UBSan) - CI中运行
- Thread Sanitizer (TSan) - 手动运行,为Apple Silicon做准备时大量使用
渐进式推广:
- 从单元测试开始(依赖少,易修复)
- 再到DSP回归测试
- 最后到端到端测试
- 一个库一个库地启用,不要一次全开
结果:
- 修复了大量潜在崩溃
- 神秘的参数自动化线条抖动bug消失了(TSan修复的threading问题)
- Apple Silicon移植顺利
- 2500天的老ticket终于关闭
金句:
- "不要假设你发现了编译器bug,99%情况下是你的问题"
- "不要试图理解UB为什么能工作,浪费脑容量"
- "如果测试有时红有时绿,比一直红更可怕"
一个活生生的案例:25年老代码库,从"UB是nerd话题"到"全公司都怕UB"的转变过程
Bjarne的Generic Programming教学talk,讲concepts的设计理念和实际应用
核心理念:
Alex Stepanov的目标是"最通用、最高效、最灵活的概念表示"。规则是:
- 不为抽象而抽象
- 从具体的、高效的算法抽象出来
- 维持性能
Bjarne的补充:
- 用代码分离表示概念
- 自由组合概念
- 最小化开销
- 让泛型编程和非泛型一样简单
实战案例:修复C++的narrowing conversion
从一个简单问题开始:C++的隐式类型转换太危险了
// 这些都能编译通过,但结果可能不是你想的
int x = 3.14; // 截断
unsigned u = -1; // 变成超大正数
char c = 1000; // 溢出解决方案 - Number类型:
template <typename T>
concept Number = std::integral<T> || std::floating_point<T>;
template <Number T>
class number {
T val;
public:
template <Number U>
number(U v) {
if (can_narrow<T, U>()) {
if (narrows(v)) throw narrowing_error{};
}
val = static_cast<T>(v);
}
};can_narrow是编译期检查,只有可能narrowing的情况才做运行时检查。大部分转换在编译期就能确定安全,零开销
类型推导简化语法:
// 不用写
number<int> x = 5;
// 直接写
number x = 5; // 推导为number<int>
number y = 5.0; // 推导为number<double>修复比较操作:
// C++的坑
-1 < 2u // false!因为-1转成unsigned变超大数
// number的修复
template <Number T, Number U>
bool operator<(number<T> a, number<U> b) {
if constexpr (/* mixed signed/unsigned */) {
if (/* 特殊情况检查 */) return true;
}
return a.val < b.val; // 安全的比较
}Range checking - 修复span:
template <spannable R>
class span {
T* data;
number<size_t> size; // 用number保证size非负
T& operator[](number<size_t> i) {
if (i >= size) throw range_error{};
return data[i];
}
};现在span s(data, -500)会在构造时就抛异常,不会等到运行时才崩溃
类型推导让代码更简洁:
// 不用写类型
span s1{aa}; // 推导为span<int>
span s2 = s1.first(10); // 前10个元素
span s3 = s1.subspan(5, 10); // 中间10个元素经典STL算法加concepts:
老版本sort:
template <typename RandomIt, typename Compare>
void sort(RandomIt first, RandomIt last, Compare comp);问题:如果传list的iterator,错误信息是"no operator+ for list::iterator",看不出是你传错了类型
新版本sort:
template <std::random_access_iterator RandomIt,
std::indirect_strict_weak_order<RandomIt> Compare>
requires sortable<RandomIt, Compare>
void sort(RandomIt first, RandomIt last, Compare comp);传list的iterator会得到清晰错误:"list::iterator is not a random_access_iterator"
Range版本更简洁:
template <sortable_range R>
void sort(R& r);
// 使用
std::vector<double> v = {3.14, 1.41, 2.71};
sort(v); // 就这么简单Forward iterator的sort(为list特化):
template <forward_sortable_range R>
void sort(R& r) {
std::vector temp(r.begin(), r.end()); // 拷到vector
std::sort(temp.begin(), temp.end());
std::copy(temp.begin(), temp.end(), r.begin()); // 拷回去
}Overload resolution会自动选最匹配的:vector用random_access版本,list用forward版本
Static Reflection预览(C++26):
// 自动生成class的member描述
template <typename T>
auto layout_of() {
constexpr auto members = nonstatic_data_members_of(^T); // ^ 是reflection operator
std::array<MemberDescriptor, members.size()> result;
for (size_t i = 0; i < members.size(); ++i) {
result[i] = {
.name = identifier_of(members[i]),
.offset = offset_of(members[i]),
.size = size_of(type_of(members[i]))
};
}
return result;
}
struct X { int a; double b; char c; };
auto xd = layout_of<X>(); // 自动生成member信息不再需要宏,编译器直接告诉你class的结构
设计决策:
- Concepts是函数 - 可以有多个参数,返回bool
- Use patterns而非固定接口 -
a + b能工作就行,不管是member还是free function - 不需要完美的concepts - 可以partial constraint,后续再完善
- Template不应该独立编译 - 需要看到实际使用才能做优化(如
advance对random_access_iterator可以用+=)
OOP vs Generic Programming:
- OOP:虚函数,固定接口,运行时多态
- Generic:concepts,灵活接口,编译期多态
它们是互补的!可以写一个generic的draw_all,既支持虚函数的class hierarchy,也支持任何有draw()方法的类型
总结:
Generic Programming不是模板编程,是concept-based programming。Concepts让你:
- 写出类型安全的泛型代码
- 获得清晰的错误信息
- 零运行时开销
- 代码简洁易读
37行代码实现number类型,修复了C++ 50年的narrowing conversion问题。这就是Generic Programming的威力
- asteria 一个脚本语言,可嵌入,长期找人,希望胖友们帮帮忙,也可以加群753302367和作者对线
