【问题标题】:C++ how to trace down reason for code not saturating CPUC ++如何追踪代码未使CPU饱和的原因
【发布时间】:2020-12-22 19:16:25
【问题描述】:

我有一个在 Windows 10 上运行的多线程 c++ 应用程序,系统具有两个 Xeon SP Gold CPU 和 160gb 内存。应用程序启动std::thread::hardware_concurrency() 许多工作线程并处理数据。但是,在处理管道的某个时刻,我在所有内核上的任务管理器中报告的 CPU 负载都会下降(下降到 2-5%)。几个小时后,管道的那部分正常完成,但我正试图找出 CPU 内核未完全利用的原因。

这部分代码没有磁盘(或其他)I/O,只对内存中已经存在的数据进行操作(内存分布在两个 NUMA 节点上,但我已经将工作线程分为两组并分配了亲和掩码对于每个只有一个节点集的 cpu 核心的每个节点,并且只有很少的内存被多个线程访问,但这根本没有提高性能),任务管理器不报告任何硬页面错误并且只有少数几个同步点。

我已经开始检测执行,但到目前为止,我的跟踪没有显示任何可疑之处,所有线程似乎都在执行工作,只是速度很慢而且任务管理器显示内核大部分处于空闲状态。

所以目前我能想到的只有:

  • 我不知道的代码中某处的某些互斥锁
  • 由于碎片或其他原因导致堆分配非常缓慢(应用程序在具有 160gb 物理内存并具有大约 90gb 工作集内存的机器上运行)
  • 软页面错误/TLB 未命中(任务管理器未报告)或某些 NUMA 相关问题
  • 我只是忽略了一些非常明显的事情(最有可能的情况)

我的应用程序链接到几个静态库并编译成一个 exe。我正在用 MSVC 编译它(但我没有 Visual Studio 项目,我正在使用 Bazel 构建)。

我可以做些什么来追踪这个问题并找出线程在哪里以及为什么要休眠?

//编辑:由于 cmets 建议在问题中添加一些代码,因此它不会被关闭,这是线程管理(尽管我很确定问题不在这里)

class ThreadedRunner {
 public:
  ThreadedRunner()
      : is_running_(true),
        func_(nullptr),
        barrier_(nullptr),
        count_(0),
        thread_(&ThreadedRunner::ThreadFunc, this) {}

  ~ThreadedRunner() {
    {
      std::unique_lock<std::mutex> lock(mutex_);
      is_running_ = false;
      condvar_.notify_all();
    }
    thread_.join();
  }

  bool Run(std::function<void(int)>* func, absl::Barrier* barrier,
           int thread_index, int thread_count, int count) {
    std::unique_lock<std::mutex> lock(mutex_);
    if (func_ != nullptr) {
      return false;
    }
    func_ = func;
    barrier_ = barrier;
    thread_index_ = thread_index;
    thread_count_ = thread_count;
    count_ = count;
    lock.unlock();
    condvar_.notify_all();
    return true;
  }

  std::thread& MutableThread() { return thread_; }

 private:
  void ThreadFunc() {
    std::unique_lock<std::mutex> lock(mutex_);
    while (true) {
      // Wait for an incoming task.
      while (is_running_ && func_ == nullptr) {
        condvar_.wait(lock);
      }
      if (!is_running_) {
        return;
      }
      assert(func_ != nullptr);

      // Execute!
      lock.unlock();
      for (int i = thread_index_; i < count_; i += thread_count_) {
        (*func_)(i);
      }

      absl::Barrier* previous_barrier = barrier_;
      // Mark this ThreadedRunner as available for another task before blocking on the current barrier.
      barrier_ = nullptr;
      thread_index_ = 0;
      thread_count_ = 0;
      count_ = 0;
      func_ = nullptr;
      if (previous_barrier->Block()) delete previous_barrier;

      lock.lock();
    }
  }

  std::mutex mutex_;
  std::condition_variable condvar_;
  bool is_running_;
  std::function<void(int)>* func_;
  absl::Barrier* barrier_;
  int thread_index_;
  int thread_count_;
  int count_;
  std::thread thread_;
};

class ThreadedRunnerPool {
 public:
  ThreadedRunnerPool() {
    ULONG highest_node_number;
    GetNumaHighestNodeNumber(&highest_node_number);
    printf("highest NUMA node: %lu\n", highest_node_number);
    num_numa_nodes_ = highest_node_number + 1;
    runners_.resize(num_numa_nodes_);
  }

  void Run(int thread_count, int count, std::function<void(int)>* func) {
    absl::Barrier* barrier = new absl::Barrier(thread_count + 1);
    {
      std::unique_lock<std::mutex> lock(mutex_);

      int started = 0;
      std::vector<int> index_per_numa_node(num_numa_nodes_, 0);
      while (started < thread_count) {
        int numaIdx = started % num_numa_nodes_;
        int& idx = index_per_numa_node[numaIdx];
        if (idx >= runners_[numaIdx].size()) {
          ThreadedRunner* runner = new ThreadedRunner();
          if(num_numa_nodes_ > 1) {
            std::thread& thread = runner->MutableThread();
            unsigned char node = static_cast<unsigned char>(numaIdx);
            unsigned long long processorMask;
            GetNumaNodeProcessorMask(node, &processorMask);
            printf("node %u processor mask: %llx\n", (unsigned int) node, processorMask);
            SetThreadAffinityMask(thread.native_handle(), processorMask);
          }
          runners_[numaIdx].emplace_back(runner);
        }
        if (runners_[numaIdx][idx]->Run(func, barrier, started, thread_count, count)) {
          ++started;
        }
        ++idx;
      }
    }

    if (barrier->Block()) delete barrier;
  }

 private:
  std::mutex mutex_;
  size_t num_numa_nodes_;
  std::vector<std::vector<std::unique_ptr<ThreadedRunner>>> runners_;
};

void ParallelFor(int thread_count, int count, std::function<void(int)> func) {
  static ThreadedRunnerPool* s_Pool = new ThreadedRunnerPool();
  s_Pool->Run(thread_count, count, &func);
}

更新:

我现在已经全局替换了 newdelete 运算符(以及 new[]delete[])以将这些调用包含在我的分析跟踪中,我现在看到执行时间由一些非常长的new 和 delete 调用,每个几百毫秒。这可能是什么原因?堆碎片?如果是这样,如何打击它?我已经在尝试尽可能预分配内存。

更新#2:

附加@mpoeter 推荐的工具还表明,我的工作线程在ucrtbase.dll!_malloc_baseucrtbase.dll!_free_base 内有超过90% 的时间处于同步状态,因此看起来堆分配和释放非常慢。知道这可能是什么原因吗?

【问题讨论】:

  • 在什么操作系统上?使用 Linux 可能会更容易,例如使用strace(1)。还可以考虑在您的问题中显示一些minimal reproducible example。没有更多细节,不清楚。
  • 这个问题很有趣,但您可能会包含一些示例代码,以确保没有人会以“寻找工具推荐”或类似原因关闭它。
  • @BasileStarynkevitch 在 Windows 10 上运行
  • @pptaszni 我想这样做,但我什至不知道从哪里开始或粘贴哪些代码部分。这是一个庞大的软件项目。我可以粘贴线程运行器的代码,但我很确定问题不存在
  • 这么复杂的逻辑有什么目的。为什么不直接使用 OpenMP。跑步者完成后你会清理他们吗?

标签: c++ multithreading performance


【解决方案1】:

您写道您正在使用 Bazel,但您可能仍想尝试一下 Visual Studio 中的分析工具(社区版本就足够了)。我将从一个简单的抽样运行开始。有关更多详细信息,您可以安装 Concurrency Visualizer 插件。使用并发可视化工具,您可以获得更多洞察力(例如哪个线程正在等待锁定 + 哪个线程持有锁定多长时间)。但是,捕获所有这些数据会产生非常大的文件,因此您的并发运行最多只能捕获几分钟的数据。您拥有的线程越多,数据就越多,因此您可能希望密切关注跟踪文件并在达到 1GB 时停止分析,否则几乎不可能处理该文件(至少在以前的工作室版本中是这种情况)。

对于采样和并发运行,您可以启动使用 Bazel 构建的二进制文件,并将 Visual Studio 分析器附加到现有进程。

【讨论】:

  • 多么美妙的工具啊!非常感谢您!!看起来我的工作线程大部分时间都卡在ucrtbase.dll!_malloc_baseucrtbase.dll!_free_base 内的同步中,所以问题肯定与堆有关。知道该怎么做吗?
【解决方案2】:

我将其发布为答案,因为我能够通过为每个线程创建单独的堆来解决问题。我正在使用 thread_local 存储来跟踪堆句柄,所以我不需要互斥锁来同步访问(我没有对堆使用 HEAP_NO_SERIALIZE,因为线程仍然有可能删除由其他线程创建的对象,在这种情况下多个线程可能访问同一个堆),但是使用此代码,我的 cpu 利用率从 2% 上升到完全 100%,而不必等待一天让我的管道在一个小时内完成。我猜 Windows 10 堆没有针对来自许多线程的大量大分配进行优化。无论如何,对解决方案感到满意:

#include <windows.h>
#include <iostream>

namespace {

    thread_local HANDLE heap_handle_;

    const char* LastSystemErrorText() {
        static char err[256];
        FormatMessage(FORMAT_MESSAGE_FROM_SYSTEM, NULL, GetLastError(),
            MAKELANGID(LANG_NEUTRAL, SUBLANG_DEFAULT), (LPWSTR)err, 255, NULL);
        return err;
    }

    HANDLE CreateNewHeap() {
        HANDLE handle = HeapCreate(0, 0, 0);
        if (handle == NULL) {
            printf("Error: could not create large object heap: %s\n",
                LastSystemErrorText());
        }
        return handle;
    }

    inline bool heap_free(HANDLE handle, void* ptr) {
        bool success = HeapFree(handle, 0, ptr);
        if (!success) {
            printf("Failed to free memory: %s\n", LastSystemErrorText());
        }
        return success;
    }

    inline void* new_impl(std::size_t req_bytes) {
        // Allocate additional bytes to store which heap the memory was allocated in.
        size_t sz = req_bytes + sizeof(HANDLE);
        if (heap_handle_ == NULL) {
            heap_handle_ = CreateNewHeap();
        }
        void* ptr = HeapAlloc(heap_handle_, 0, sz);
        if (ptr) {
            *((HANDLE*)ptr) = heap_handle_;
            return (void*)(((char*)ptr) + sizeof(HANDLE));
        }
        else {
            throw std::bad_alloc{};
        }
    }

    inline void delete_impl(void* ptr) {
        if (!ptr)
            return;
        void* actual_ptr = (void*)(((char*)ptr) - sizeof(HANDLE));
        HANDLE heap_handle = *((HANDLE*)actual_ptr);
        heap_free(heap_handle, actual_ptr);
    }
}  // namespace

// globally replacing operators new and delete

void* operator new(std::size_t sz) {
    return new_impl(sz);
}

void* operator new[](std::size_t sz) {
    return new_impl(sz);
}

void operator delete(void* ptr) noexcept
{
    delete_impl(ptr);
}

void operator delete[](void* ptr) noexcept
{
    delete_impl(ptr);
}

【讨论】:

    【解决方案3】:

    有些工具可以帮助您。在 linux 上 perf 使用硬件计数器和中断来查看哪些函数存在性能问题。

    Ftrace 和 Strace 将让您了解诸如互斥锁阻塞之类的事情发生了什么。

    L

    简要查看发布的代码是在互斥体中运行 _func。这将阻止其他线程在执行时运行。为了获得最佳性能,只需锁定一小段时间。

    【讨论】:

    • 谢谢!在 Windows 上也有类似的东西吗?只有将新任务分配给 threadrunners 会被互斥锁锁定,而不是执行
    • 简单的想法是让它在调试器中运行并随机停止它,看看你的线程在做什么。
    猜你喜欢
    • 2011-12-25
    • 2012-10-19
    • 1970-01-01
    • 2019-03-17
    • 2012-08-29
    • 2012-12-06
    • 2015-10-20
    • 1970-01-01
    • 1970-01-01
    相关资源
    最近更新 更多