计时机制中如何以最快速度存储定长字符串标签
核心思路是把所有重操作从计时热路径移走,觉得标签存储开销高,本质是把字符串拷贝、复杂处理逻辑放在了step()打点的临界区,完全没必要。按开销从低到高,几个可直接落地的方案:
方案1:零拷贝延迟读取(推荐,额外开销<5CPU周期)
这是性能损耗最低的方案,打点全程不需要做任何字符串拷贝:
- 计时热路径(
start/step/end调用时)只存两个值:标签字符串的const char*指针、字符串长度,直接写入预分配的std::array,全程无内存分配、无内存拷贝 - 所有字符串读取、拷贝、文件IO操作全部移到
end()调用、最后一个时间戳采集完成之后再执行——这时候计时已经完全结束,后续操作哪怕开销再高,都不会影响采集到的rdtsc数值准确性。
绝大多数性能打点场景下,传入的标签都是全局生命周期的字符串字面量,指针永久有效,完全不需要提前拷贝。对于字面量参数,编译器可以通过内置函数直接在编译期算出字符串长度,连运行期遍历字符串算长度的开销都能省掉。
核心实现片段:
#include <cstdint> #include <array> #include <cstdio> #include <immintrin.h> template<size_t NumSteps> struct timer { // 存储点位:start + NumSteps个step + end,共NumSteps+2个点 std::array<uint64_t, NumSteps + 2> tsc_buf; // 标签仅存指针+长度,无任何拷贝 std::array<std::pair<const char*, size_t>, NumSteps + 2> tag_buf; size_t cur = 0; void start() { tsc_buf[cur] = __rdtsc(); tag_buf[cur] = {"start", 5}; // 指针和长度编译期直接确定 cur++; } void step(const char* tag) { // 热路径仅3个操作:读tsc、算长度(常量直接编译期求值)、写指针和长度 tsc_buf[cur] = __rdtsc(); size_t tag_len = __builtin_strlen(tag); tag_buf[cur] = {tag, tag_len}; cur++; } void end() { // 先采集最后一个时间戳,所有计时操作到此结束 tsc_buf[cur] = __rdtsc(); tag_buf[cur] = {"end", 3}; cur++; // 后续写文件、字符串处理全部在计时完成后执行,对精度无影响 FILE* f = fopen("tsc_trace.log", "a"); for (size_t i = 0; i < cur; i++) { fprintf(f, "%llu | %.*s\n", (unsigned long long)tsc_buf[i], (int)tag_buf[i].second, tag_buf[i].first); } fclose(f); } }; // 使用示例 void funcToTime() { timer<2> t; t.start(); // ... 第一段待计时逻辑 t.step("hi"); // ... 第二段待计时逻辑 t.step("bye"); // ... 第三段待计时逻辑 t.end(); }
O2优化下,上述step函数的热路径汇编不到10条指令,额外开销不超过5个CPU周期,和rdtsc指令本身的开销(约10~20周期)比完全可以忽略。
如果需要兼容短生命周期的字符串标签,只需要给step加一个重载,对传入的临时字符串做定长拷贝到预留的栈上缓冲区即可,这类场景占比不到1%,不影响默认路径的性能。
方案2:定长小字符串内联存储(兼容短生命周期标签,额外开销<10CPU周期)
如果确实需要在打点时就把标签拷贝到计时器内部,避免生命周期问题,不要用std::string或者通用安全检查版字符串函数:
- 利用
MaxTagLen是编译期常量的特性,直接预留std::array<char, MaxTagLen+1>的定长缓冲区,不需要任何动态内存 - 拷贝操作直接按机器字长展开(比如64位系统一次拷8字节),不要调用运行期的
strcpy/memcpy,编译器会对固定长度的拷贝直接展开为寄存器mov指令,10字节左右的标签只需要2次内存写操作,开销极低 - 不需要额外存字符串长度,写入时在末尾补
\0即可,读的时候遇到结束符自动停止
这种方案的开销比零拷贝方案略高,但依然在10个周期以内,完全满足cycle级计时的精度要求。
方案3:标签预注册(极致零开销,适合固定打点场景)
如果对开销要求到极致,可以在计时器初始化前提前把所有要用到的标签注册到全局静态表,每个标签对应一个整数ID。打点时只需要传入标签ID,热路径上仅存储一个整数,和存rdtsc值的开销完全一致。等写文件时再通过ID查表拿到对应的标签字符串即可。
缺点是使用时需要提前注册标签,灵活度稍差,适合打点点位和标签完全固定的场景。
常见误区
之前觉得字符串拷贝开销过高,大概率是在Debug模式下测试,或者用了std::string的通用赋值逻辑、带边界检查的字符串函数。开O2优化后,10字节以内的固定长度字符串拷贝会被编译器完全展开为寄存器操作,没有任何函数调用开销,实际耗时远低于预期,完全不会影响计时精度。
内容的提问来源于stack exchange,提问作者bun9

