如何在性能至关重要时编写Linux C ++调试信息?

时间:2016-03-29 21:51:48

标签: c++ performance debugging

我试图用很多变量来调试一个相当大的程序。代码以这种方式设置:

while (condition1) {
    //non timing sensitive code
    while (condition2) {
    //timing sensitive code
    //many variables that change each iteration
    }
}

我在内循环上有很多变量要保存以供查看。我想将它们写入每个外部循环迭代的文本文件中。内循环每次迭代执行不同的次数。它可以只有2或3,也可以是几千。

我需要查看每个内部迭代的所有变量值,但我需要尽可能快地保持内部循环。

最初,我尝试将每个数据变量存储在自己的向量中,我只是在每个内部循环迭代中附加一个值。然后,当外循环迭代到来时,我将从向量读取并将数据写入调试文件。随着变量的增加,这很快就会失控。

我考虑使用字符串缓冲区来存储信息,但是我不确定这是否是在循环内需要多次创建字符串的最快方法。此外,由于我不知道迭代次数,我不确定缓冲区会有多大。

存储的信息格式如下:

"Var x: 10\n
Var y: 20\n
.
.
.
Other Text: Stuff\n"

那么,是否有更快速的选项可以快速写入大量的调试数据?

2 个答案:

答案 0 :(得分:2)

如果它真的对时间敏感,那么就不要在关键循环中格式化字符串。

我要将记录附加到关键循环内的二进制记录的日志缓冲区中。外部循环可以直接将其写入二进制文件(可以在以后处理),也可以根据记录格式化文本。

这样做的好处是循环只需跟踪一些额外的变量(指向 one std::vector的已用和已分配空间的结尾指针),而不是两个每个记录变量的std::vector指针。这对关键循环中的寄存器分配的影响要小得多。

在我的测试中,看起来你只需要一些额外的循环开销来跟踪向量,并为你想要记录的每个变量提供存储指令。我没有写一个足够大的测试循环来揭露任何潜在的问题,以保持所有变量" alive"直到emplace_back()。如果编译器在需要溢出寄存器的较大循环中执行错误的操作,请参阅下面有关使用简单数组而不进行任何大小检查的部分。这应该删除编译器上的任何约束,使其尝试同时将所有存储放入日志缓冲区。

这是我所建议的一个例子。它编译并运行,编写一个可以进行hexdump的二进制日志文件。

Godbolt compiler explorer上查看源和asm输出,格式不错。它甚至可以对源和asm线进行着色,这样您就可以更容易地看到哪个asm来自哪个源代码行。

#include <vector>
#include <cstdint>
#include <cstddef>
#include <iostream>

struct loop_log {
    // Generally sort in order of size for better packing.
    // Use as narrow types as possible to reduce memory bandwidth.
    // e.g. logging an int loop counter into a short log record is fine if you're sure it always in-practice fits in a short, and has zero performance downside
    int64_t x, y, z;
    uint64_t ux, uy, uz;
    int32_t a, b, c;
    uint16_t t, i, j;
    uint8_t c1, c2, c3;
    // isn't there a less-repetitive way to write this?
    loop_log(int64_t x, int32_t a, int outer_counter, char c1)
      : x(x), a(a), i(outer_counter), c1(c1)
        // leaves other members *uninitialized*, not zeroed.
        // note lack of gcc warning for initializing uint16_t i from an int
        // and for not mentioning every member
      {}
};


static constexpr size_t initial_reserve = 10000;

// take some args so gcc can't count the iterations at compile time
void foo(std::ostream &logfile, int outer_iterations, int inner_param) {
    std::vector<struct loop_log> log;
    log.reserve(initial_reserve);
    int outer_counter = outer_iterations;

    while (--outer_counter) {
        //non timing sensitive code
        int32_t a = inner_param - outer_counter;
        while (a != 0) {
            //timing sensitive code
            a <<= 1;
            int64_t x = outer_counter * (100LL + a);
            char c1 = x;

            // much more efficient code with gcc 5.3 -O3  than push_back( a struct literal );
            log.emplace_back(x, a, outer_counter, c1);
        }
        const auto logdata = log.data();
        const size_t bytes = log.size() * sizeof(*logdata);
        // write group size, then a group of records
        logfile.write( reinterpret_cast<const char *>(&bytes), sizeof(bytes) );
        logfile.write( reinterpret_cast<const char *>(logdata), bytes );
        // you could format the records into strings at this point if you want
        log.clear();
    }
}


#include <fstream>
int main() {
    std::ofstream logfile("dbg.log");
    foo(logfile, 100, 10);
}

gcc的foo()输出几乎可以优化所有向量开销。只要初始reserve()足够大,内部循环就是:

## gcc 5.3 -masm=intel  -O3 -march=haswell -std=gnu++11 -fverbose-asm
## The inner loop from the above C++:
.L59:
        test    rbx, rbx        # log      // IDK why gcc wants to check for a NULL pointer inside the hot loop, instead of doing it once after reserve() calls new()
        je      .L6 #,
        mov     QWORD PTR [rbx], rbp      # log_53->x, x     // emplace_back the 4 elements
        mov     DWORD PTR [rbx+48], r12d  # log_53->a, a
        mov     WORD PTR [rbx+62], r15w   # log_53->i, outer_counter
        mov     BYTE PTR [rbx+66], bpl    # log_53->c1, x
.L6:
        add     rbx, 72   # log,                // struct size is 72B
        mov     r8, r13   # D.44727, log
        test    r12d, r12d      # a
        je      .L58        #,                  // a != 0
.L4:
        add     r12d, r12d        # a           // a <<= 1
        movsx   rbp, r12d     # D.44726, a      // x = ...
        add     rbp, 100  # D.44726,            // x = ...
        imul    rbp, QWORD PTR [rsp+8]  # x, %sfp   // x = ...
        cmp     r14, rbx  # log$D40277$_M_impl$_M_end_of_storage, log
        jne     .L59      #,                    // stay in this tight loop as long as we don't run out of reserved space in the vector

       // fall through into code that allocates more space and copies.
       //  gcc generates pretty lame copy code, using 8B integer loads/stores, not rep movsq.  Clang uses AVX to copy 32B at a time
       // anyway, that code never runs as long as the reserve is big enough
       // I guess std::vector doesn't try to realloc() to avoid the copy if possible (e.g. if the following virtual address region is unused) :/

试图避免重复的构造函数代码:

我尝试了一个使用支撑初始化列表的版本,以避免编写一个非常重复的构造函数,但是从gcc获得了更糟糕的代码:

#ifdef USE_CONSTRUCTOR
            // much more efficient code with gcc 5.3 -O3.
            log.emplace_back(x, a, outer_counter, c1);
#else
            // Put the mapping from local var names to struct member names right here in with the loop
            log.push_back( (struct loop_log) {
              .x = x, .y =0, .z=0,    // C99 designated-initializers are a GNU extension to C++,
              .ux=0,  .uy=0, .uz=0,   // but gcc doesn't support leaving having uninitialized elements before the last initialized one:
              .a = a, .b=0,  .c=0,    //  without all the ...=0, you get "sorry, unimplemented: non-trivial designated initializers not supported"
              .t=0,   .i = outer_counter, .j=0,
              .c1 = (uint8_t)c1
            } );
#endif

不幸的是,这会将一个结构存储到堆栈中,然后一次将其复制到8B,代码如下:

    mov     rax, QWORD PTR [rsp+72]
    mov     QWORD PTR [rdx+8], rax    // rdx points into the vector's buffer
    mov     rax, QWORD PTR [rsp+80]
    mov     QWORD PTR [rdx+16], rax
    ...  // total of 9 loads/stores for a 72B struct

因此它会对内循环产生更大的影响。

push_back() a struct into a vector有几种方法,但遗憾的是,使用braced-initializer-list似乎总是会产生一个没有被gcc 5.3优化掉的副本。避免为构造函数编写大量重复代码会很好。使用指定的初始化列表({.x = val}),循环内的代码不必太在意结构实际存储的顺序。你可以用易于阅读的顺序编写它们。

BTW,.x= val C99指定初始化语法是C ++的GNU扩展。此外,您可以获取警告,因为忘记使用gcc&#39; -Wextra -Wmissing-field-initializers启用括号列表中的成员。

有关初始化程序语法的更多信息,请查看Brace-enclosed initializer list constructorthe docs for member initialization

这是一个有趣但可怕的想法:

// Doesn't compiler.  Worse: hard to read, probably easy to screw up
while (outerloop) {
  int64_t x=0, y=1;
  struct loop_log {int64_t logx=x, logy=y;};  // loop vars as default initializers
  // error: default initializers can't be local vars with automatic storage.
  while (innerloop) { x+=y; y+=x;  log.emplace_back(loop_log()); }
}

使用平面阵列而不是std::vector

降低开销

也许试图让编译器优化掉任何类型的std::vector操作不仅仅是制作大量的结构(静态,本地或动态)并且自己计算有多少记录。有效。 std::vector会检查您是否在每次迭代时都用尽了预留的空间,但是如果,那么你不需要这样的东西可以用来分配足够的空间来永不溢出。 (根据平台以及如何分配空间,分配但从未编写过的大量内存并不是真正的问题。例如,在Linux上,malloc使用mmap(MAP_ANONYMOUS)进行大量分配,并且它为您提供了所有写入时写入映射到零化物理页面的页面。操作系统不需要分配物理页面直到您编写它们。同样应该适用于大型静态数组。)< / p>

所以在你的循环中,你可以拥有像

这样的代码
loop_log *current_record = logbuf;
while(inner_loop) {
    int64_t x = ...;
    current_record->x = x;
    ...
    current_record->i = (short)outer_counter;
    ...
    // or maybe
    // *current_record = { .x = x, .i = (short)outer_counter };
    // compilers will probably have an easier time avoiding any copying with a braced initializer list in this case than with vector.push_back

    current_record++;
}
size_t record_bytes = (current_record - log) * sizeof(log[0]);
// or size_t record_bytes = static_cast<char*>(current_record) - static_cast<char*>(log);
logfile.write((const char*)logbuf, record_bytes);

在整个内循环中散布存储将要求数组指针始终处于活动状态,但OTOH并不要求所有循环变量同时存在。 IDK,如果不再需要变量,gcc会优化emplace_back将每个变量存储到向量中,或者是否可能将变量溢出到堆栈中,然后将它们全部复制到一组指令中的向量中。 / p>

使用log[records++].x = ...可能会导致编译器保持数组和计数器占用两个寄存器,因为我们在外部循环中使用记录计数。我们希望内部循环很快,并且可以花时间在外部循环中进行减法,所以我用指针增量编写它以鼓励编译器仅为该段状态使用一个寄存器。除了注册压力,base+index store instructions are less efficient on Intel SnB-family hardware than single-register addressing modes

您仍然可以使用std::vector,但很难让std::vector不将零写入其分配的内存中。 reserve()只是在没有归零的情况下进行分配,但是您调用.data()并使用保留空间而不告诉vector它与.resize()类型的目的无效。当然.resize()将初始化所有新元素。所以你std::vector是一个不好的选择,可以让你的大量分配而不会弄脏它。

答案 1 :(得分:0)

听起来你真正想要的是从调试器中查看你的程序。你还没有指定一个平台,但是如果使用调试信息构建(-g使用gcc或clang),你应该能够在调试器(linux上的gdb)启动程序时逐步完成循环。假设你在linux,告诉它在函数开头打破(break)然后运行。如果您告诉调试器在每个步骤或断点命中后显示您想要查看的所有变量,您将立即找到问题的根源。

关于性能:除非你做一些像设置条件断点或监视内存这样的事情,否则只要程序没有停止,通过调试器运行程序就不会显着影响性能。您可能需要调低优化级别以获取有意义的信息。