TBB 框架
- 注意
- 由于 ProTBB 和 OneTBB 有较大的差异,这里只总结存在交集的部分
TBB 并行执行接口
- Generic Parallel Algorithms
- Flow Graph
- Task Arenas
- Global Control
TBB 一些独立的执行模型
- 并发容器(ConCurrent Containers)
- Memory Allocation
- Scalable Allocator
- Cache Aligned Allocator
- 等
- Primitives 和 Utilities
- 并行调度是有开销的
- 开销不同硬件略有不同,任务应至少执行 1 微秒或 10,000 个处理器周期
- 随着处理器的不断升级,核心不断增加,上述时间可以提升至 10 微秒(至少我的笔记本测试是这样的结果)
TBB Ranges
- Range 可以在执行并行任务时递归地细分为两部分
- 细分是通过调用 Range 的拆分构造函数来完成的
- 有两种类型的拆分构造函数
- 基本拆分构造函数
- 建议进行均等划分
- 尽可能均匀地拆分通常会产生最佳的并行性效果
- 比例分割构造函数
- 可选
- 可以与 is_splittable_in_proportion 变量一起省略
- 为了获得最佳结果
- 模板类 blocked_range 的 grainsize 参数
- blocked_range 的种类
- tbb::blocked_range
- tbb::blocked_range2d
- tbb::blocked_range3d
Partitioners 分区器
- 指定循环模板如何在线程之间划分工作
- 循环模板 parallel_for、parallel_reduce 和 parallel_scan 默认行为
- 尝试以递归方式将 Ranges 拆分为足够多的部分以保持处理器忙碌,而不一定尽可能细地拆分
- 分区器种类
- tbb::auto_partitioner
- tbb::simple_partitioner
- tbb::affinity_partitioner
- tbb::static_partitioner
Generic Parallel Algorithms
- parallel_invoke
- parallel_for
- parallel_reduce
- parallel_deterministic_reduce
- parallel_scan
- parallel_for_each
- parallel_sort
- parallel_pipeline
parallel_invoke
parallel_for
- 对一系列值进行并行迭代
- 当 TBB 执行 parallel_for 时,Range 被划分为迭代块
- 每个块与 Body 配对,成为一项任务,并被调度到参与执行算法的线程上
- 在处理双重循环时,最好尽可能使外层循环并行以保持较低的开销
- 可以使用分割器种类
- tbb::auto_partitioner
- tbb::simple_partitioner
- tbb::affinity_partitioner
- tbb::static_partitioner
- 使用方式
tbb::task_group_context tg;
tbb::parallel_for(0, 100, [=](int i)
{
},
tbb::auto_partitioner(),
tg
);
parallel_reduce and parallel_deterministic_reduce
- parallel_deterministic_reduce 是 parallel_reduce 的固定精度版本
- 如果对计算浮点数的精度有要求,并行时选 parallel_deterministic_reduce
- 对应模式 reduce pattern 或 map-reduce
- 应用场景
- 可以使用分割器种类
- tbb::auto_partitioner
- tbb::simple_partitioner
- tbb::affinity_partitioner
- tbb::static_partitioner
- 使用方式
tbb::task_group_context tg;
double sum = tbb::parallel_reduce(
tbb::blocked_range<int>(0, numIntervals),
0.0,
[=](const tbb::blocked_range<int>& r, double init) -> double
{
for(int i=r.begin(); i!=r.end(); ++i)
{
double x = (i + 0.5) * dx;
double h = std::sqrt(1. - x * x);
init += h * dx;
}
return init;
},
[](double x, double y) -> double
{
return x + y;
},
tbb::auto_partitioner(),
tg
);
parallel_scan
- 并行计算一系列值的前缀
- 由于并行版本需要重新关联扫描的结果,因此会比串行算法调用更多次的关联计算
- 但只要粒度合适,并行算法的性能就可以优于串行算法
- 建议使用具有两个以上内核的系统上
- 个人测试总结
- 可以使用分割器种类
- tbb::auto_partitioner
- tbb::simple_partitioner
- 使用方式
int finalSum = tbb::parallel_scan(
tbb::blocked_range<int>(1, n),
(int) 0,
[&v, &rsum](const tbb::blocked_range<int>& r, int sum, bool isFinalScan) -> int
{
for(int i=r.begin(); i<r.end(); ++i)
{
sum += v[i];
if(isFinalScan)
{
rsum[i] = sum;
}
}
return sum;
},
[](int x, int y)
{
return x + y;
},
tbb::auto_partitioner()
);
parallel_for_each
- std::for_each 的并行实现
- 并行处理容器中的工作项(元素),并能够动态添加其他工作项,直到没有剩余元素为止
- 如果主体并行执行并添加新项,则这些项也可以并行生成
- 适用情况
- 需要在循环中表达并行性,在循环开始之前无法完全计算范围
- 不提供随机访问迭代器的容器
- 由于必须按顺序遍历容器,因此 parallel_for_each 的可扩展性不如 parallel_for
- 但只要主体相对较大(> 100,000 个时钟周期),与在元素上并行执行主体相比,遍历开销将可以忽略
- 使用方式
tbb::task_group_context tg;
PrimesTreeElement::Ptr treeArray[] = { root };
tbb::parallel_for_each(
treeArray,
[](PrimesTreeElement::Ptr e,
tbb::feeder<PrimesTreeElement::Ptr>& feeder)
{
if(e)
{
if(e->left)
{
feeder.add(e->left);
}
if(e->right)
{
feeder.add(e->right);
}
if(isPrime(e->v.first))
{
e->v.second = true;
}
}
},
tg
);
parallel_sort
parallel_pipeline
- 表示由一系列过滤器组成的流水线程序,应用于数据流中,这些过滤器可以并行运行
- 每个过滤器 (tbb::filter_mode)都可以使用特定模式运行
- parallel
- serial in-order
- serial out-of-order
- Flow Graph 也可以实现管道模式
- Flow Graph 比 parallel_pipeline 更通用
- 使用方式
int numTokens = tbb::this_task_arena::max_concurrency();
tbb::task_group_context tg;
tbb::parallel_pipeline(
numTokens,
tbb::make_filter<void, CaseStringPtr>(
tbb::filter_mode::serial_in_order,
[&](tbb::flow_control& fc) -> CaseStringPtr
{
CaseStringPtr sPtr = Get_Case_String(caseBeforeFile);
if(!sPtr)
{
fc.stop();
}
return sPtr;
}
) &
tbb::make_filter<CaseStringPtr, CaseStringPtr>(
tbb::filter_mode::parallel,
[](CaseStringPtr sPtr) -> CaseStringPtr
{
std::transform(
sPtr->begin(), sPtr->end(), sPtr->begin(),
[](char c) -> char
{
if(std::islower(c))
{
return std::toupper(c);
}
else if(std::isupper(c))
{
return std::tolower(c);
}
else
{
return c;
}
}
);
return sPtr;
}
) &
tbb::make_filter<CaseStringPtr, void>(
tbb::filter_mode::serial_in_order,
[&](CaseStringPtr sPtr) -> void
{
Write_Case_String(caseAfterFile, sPtr);
}
),
tg
);
Flow Graphs
Node 节点
- 节点类型大致可分为三类
- 功能节点类型(functional)
- 控制流节点类型(control flow)
- 缓冲节点类型(buffer)
functional
- input_node
- function_node
- 将 lambda 表达式作为其参数
- 可以使用 try_put 发送信息返回 lambda 计算的结果
- continue_node
- multifunction_node
- async_node
buffering
- 种类
- buffer_node
- queue_node
- priority_queue_node
- sequencer_node
- write_once_node
- overwrite_node
- 通常与 reserving join_node 结合使用
Control Flow
- 种类
- broadcast_node
- composite_node
- limiter_node
- split_node
- indexer_node
join_node
- 可以有四种 join 策略
- queueing join
- 将传入消息存储在每个端口的队列中
- 用先进先出的方法将消息连接成一个 tuple
- reserving join
- 不缓冲传入的消息
- 会跟踪前面的缓冲区的状态
- 当认为每个输入端口都有可用的消息时, 会尝试为每个输入端口保留一个 Item
- 只有当 join_node 能够成功获得每个输入端口的元素上的保留 Item 时,它才会使用这些消息
- tag matching join
- key matching join
- 对于 queueing、key_matching 和 tag_matching 策略的 join_node
- key_matching 和 tag_matching 策略
- 将传入消息存储在每个端口的映射(per-port maps)中
- 并根据匹配的 keys 或 tags 连接消息
为图添加边
- 使用 make_edge 设置消息通道或依赖关系
- 如果一个节点有多个输入端口或输出端口
- 可以使用 input_port 和 output_port 函数模板来选择端口
make_edge(output_port<0>(predecessorNode), input_port<1>(successorNode));
启动流程图
- 消息进入流程图的主要方式有两种
- 通过显式调用 Node 的 try_put 方法
- 以 input_node 做为图的输入端
- 在非活跃的图中调用 activate() 函数启动图
等待图执行完成
- 一旦使用 try_put 或 input_node 将消息发送到图中
- 就可以通过调用图对象上的 wait_for_all() 来等待图执行完成
用于调用整个图操作
- 等待与图执行相关的所有任务完成
- 重置图中所有节点的状态
- 取消图中所有节点的执行
流程图的使用方式
tbb::flow::graph g;
tbb::flow::input_node<uint64_t> frameNoNode{
g,
[&](tbb::flow_control& fc) -> uint64_t
{
if(auto frameNumber = Get_Next_Frame_Number())
{
return frameNumber;
}
else
{
fc.stop();
return {};
}
}
};
tbb::flow::function_node<uint64_t, Image> getLeftNode
{
g,
tbb::flow::serial,
[](uint64_t frameNumber) -> Image
{
return Get_Left_Image(frameNumber);
}
};
tbb::flow::function_node<uint64_t, Image> getRightNode
{
g,
tbb::flow::serial,
[](uint64_t frameNumber) -> Image
{
return Get_Right_Image(frameNumber);
}
};
tbb::flow::function_node<Image, Image> increaseLeftNode
{
g,
tbb::flow::unlimited,
[](Image left) -> Image
{
Increase_Png_Channel(left, Image::RedOffset, 10);
return left;
}
};
tbb::flow::function_node<Image, Image> increaseRightNode
{
g,
tbb::flow::unlimited,
[](Image right) -> Image
{
Increase_Png_Channel(right, Image::BlueOffset, 10);
return right;
}
};
tbb::flow::join_node<std::tuple<Image, Image>, tbb::flow::tag_matching>
joinImagesNode
{
g,
[](Image left) -> uint64_t
{
return left.mFrameNumber;
},
[](Image right) -> uint64_t
{
return right.mFrameNumber;
}
};
tbb::flow::function_node<std::tuple<Image, Image>, Image>
mergeImagesNode
{
g,
tbb::flow::unlimited,
[](std::tuple<Image, Image> t) -> Image
{
auto& l = std::get<0>(t);
auto& r = std::get<1>(t);
Merge_Png_Images(r, l);
return r;
}
};
tbb::flow::function_node<Image> writeNode
{
g,
tbb::flow::unlimited,
[](Image img)->void
{
img.write();
}
};
tbb::flow::make_edge(frameNoNode, getLeftNode);
tbb::flow::make_edge(frameNoNode, getRightNode);
tbb::flow::make_edge(getLeftNode, increaseLeftNode);
tbb::flow::make_edge(getRightNode, increaseRightNode);
tbb::flow::make_edge(increaseLeftNode, tbb::flow::input_port<0>(joinImagesNode));
tbb::flow::make_edge(increaseRightNode, tbb::flow::input_port<1>(joinImagesNode));
tbb::flow::make_edge(joinImagesNode, mergeImagesNode);
tbb::flow::make_edge(mergeImagesNode, writeNode);
frameNoNode.activate();
g.wait_for_all();
Data Flow Graph 的性能
- 以下三个主要因素可能会限制 TBB 流程图的性能
- 串行节点
- 工作线程的数量
- 并行执行 TBB 任务的开销
依赖图(Dependency Graphs)
- 为了使用 TBB 流图类来表达有依赖关系的图,可以使用 continue_node 作为节点并传递类型为 continue_msg 的消息
- function_node 和 continue_node 的主要区别
- 如何对消息做出反应
- function_node
- 当 function_node 收到一条消息时,它会将其主体应用于该消息
- continue_node
- 会计算它收到的消息数量
- 当它收到的消息数量等于它拥有先前定义的数量时,会生成一个任务来执行其主体,然后重置其收到的消息计数
- 依赖图的使用方式
using Node = tbb::flow::continue_node<tbb::flow::continue_msg>;
using NodePtr = std::shared_ptr<Node>;
auto Create_Node(tbb::flow::graph& g, int r, int c, int blockSize,
std::vector<double>& x, const std::vector<double>& a,
std::vector<double>& b) -> NodePtr
{
const int n = x.size();
return std::make_shared<Node>(
g,
[r, c, blockSize, n, &x, &a, &b](const tbb::flow::continue_msg& msg)
{
int iStart = r * blockSize, iEnd = iStart + blockSize;
int jStart = c * blockSize, jMax = jStart + blockSize - 1;
for (int i = iStart; i < iEnd; ++i)
{
int jEnd = (i <= jMax) ? i : jMax + 1;
for (int j = jStart; j < jEnd; ++j)
{
b[i] -= a[j + i * n] * x[j];
}
if (jEnd == i)
{
x[i] = b[i] / a[i + i * n];
}
}
return msg;
}
);
}
auto Add_Edges(std::vector<NodePtr>& node, int r, int c, int blockSize, int numBlocks) -> void
{
NodePtr np = node[r * numBlocks + c];
if(c+1 < numBlocks && r!=c)
{
tbb::flow::make_edge(*np, *node[r * numBlocks + c + 1]);
}
if(r+1 < numBlocks)
{
tbb::flow::make_edge(*np, *node[(r + 1) * numBlocks + c]);
}
}
auto Graph_Forward_Substitution(std::vector<double>& x,
const std::vector<double>& a,
std::vector<double>& b) -> void
{
const int n = x.size();
constexpr int blockSize = 1024;
const int numBlocks = n / blockSize;
std::vector<NodePtr> nodes(numBlocks * numBlocks);
tbb::flow::graph g;
for(int r = numBlocks - 1; r >=0; --r)
{
for(int c = r; c>=0; --c)
{
nodes[r * numBlocks + c] = Create_Node(g, r, c, blockSize, x, a, b);
Add_Edges(nodes, r, c, blockSize, numBlocks);
}
}
nodes[0]->try_put(tbb::flow::continue_msg{});
g.wait_for_all();
}
Mutex
- TBB 锁的种类
- mutex and recursive_mutex (建议使用 C++ 标准库中的锁)
- 这些互斥锁会阻塞较长的等待,因此它们浪费的周期更少
- 但它们占用更多的空间,并且当互斥锁可用时,它们的响应时间更长
- spin_mutex(不建议使用)
- 永远不会阻塞
- 一旦互斥体被释放,响应获取它的时间是最快的
- queueing_mutex(不建议使用)
- 它仍在自旋,在用户空间中忙待
- 但等待该互斥锁的线程将获取 FIFO 中的锁秩序,所以不会产生饥饿
- speculative_spin_mutex (不建议使用)
- spin_rw_mutex、queueing_rw_mutex 和 speculative_spin_rw_mutex(不建议使用)
- 建议使用 C++ unique_lock 和 shared_lock 实现读写锁
- null_mutex 和 null_rw_mutex
原子操作 compare_and_swap (CAS)
私有化和归约 Privatization and Reduction
- TBB 提供了多种替代方案来完成 Privatization and Reduction
- thread local storage
- 线程级推测 (Thread-Level Speculation, TLS)
- reduction template
thread local storage
- 每个线程拥有私有化副本的数据
- 可以减少对线程之间共享可变状态的访问
- 副本会占用空间,因此不应过度使用
- TBB 提供了两个用于线程本地存储的模板类
- 两者都提供对每个线程本地元素的访问,并根据需要(延迟 lazily)创建元素
- enumerable_thread_specific, ETS
- 提供线程本地存储,其作用类似于 STL 容器,每个线程一个元素
- 容器允许使用常用的 STL 迭代习惯来迭代元素
- 任何线程都可以迭代所有本地副本,查看其他线程的本地数据
- 使用方式
constexpr int numBins = 256;
using Vector = std::vector<int>;
using PrivH = tbb::enumerable_thread_specific<Vector>;
PrivH privH{ numBins };
tbb::parallel_for(
tbb::blocked_range<size_t>{0, image.size()},
[&](const tbb::blocked_range<size_t>& r)
{
PrivH::reference myHist = privH.local();
for(size_t i=r.begin(); i<r.end(); i++)
{
myHist[image[i]]++;
}
}
);
- 两个额外的成员函数来实现 reduction
- combine_each()
- 使用方式
Vector histP(numBins);
privH.combine_each(
[&](Vector a)
{
std::transform(
histP.begin(),
histP.end(),
a.begin(),
histP.begin(),
std::plus<int>()
);
}
);
- combine()
- 使用方式
Vector histP = privH.combine(
[](Vector a, Vector b) -> Vector
{
std::transform(
a.begin(),
a.end(),
b.begin(),
a.begin(),
std::plus<int>()
);
return a;
}
);
- combinable
- 为每个线程提供其自己的本地实例(类型为 T)
- 每个线程只能看到其本地数据,或者在调用 combine 后看到组合后的数据
- 使用方式
using Vector = std::vector<int>;
tbb::combinable<Vector> privH{ [numBins]()
{
return Vector(numBins);
} };
tbb::parallel_for(
tbb::blocked_range<size_t>{0, image.size()},
[&](const tbb::blocked_range<size_t>& r)
{
Vector& myHist = privH.local();
for(size_t i=r.begin(); i<r.end(); i++)
{
myHist[image[i]]++;
}
}
);
Vector histP(numBins);
privH.combine_each(
[&](Vector a)
{
std::transform(
histP.begin(),
histP.end(),
a.begin(),
histP.begin(),
std::plus<int>()
);
}
);
parallel_reduce template
- 最简单的确保线程局部性的并行实现
- 使用方式
using Vector = std::vector<int>;
using ImageIterator = std::vector<uint8_t>::iterator;
Vector histP = tbb::parallel_reduce(
tbb::blocked_range<ImageIterator>(image.begin(), image.end()),
Vector(numBins),
[](const tbb::blocked_range<ImageIterator>& r, Vector v)
{
std::for_each(r.begin(), r.end(),
[&v](uint8_t i)
{
v[i]++;
}
);
return v;
},
[](Vector a, const Vector& b) -> Vector
{
std::transform(a.begin(),
a.end(),
b.begin(),
a.begin(),
std::plus<int>());
return a;
}
);
本章对一个算法进行并行实现的步骤
- 编写一个非线程安全的示例
- 加锁实现
- 精细粒度的锁实现
- 无锁实现
- 保证线程局部性的实现
- 衡量利弊,选择最容易实现、最容易理解、最高效的实现
Concurrent Containers
- 比常规 STL 容器开销更大
- 当有可能存在并发访问时,应该使用并发容器
- 如果无并发访问,建议使用STL容器
- 容器的接口与 STL 中的接口相同,专门为并发设计的接口除外
- TBB 目前提供以下并发容器
- Unordered map (including unordered multimap)
- Unordered set (including unordered multiset)
- Hash table
- Queue (包含 bounded queue 和 priority queue)
- Vector
concurrent_hash_map
- 允许多个线程通过 find、insert 和 erase 方法同时访问值的方式将键映射到值
- 使用 accessor 和 const_accessor 充当智能指针来支持读写
- concurrent_hash_map 的访问器本质上是
- accessor 是独占锁
- const_accessor 是共享锁
- 尽量使用 concurrent_hash_map
- hash_map 的性能技巧
- 指定哈希表的初始大小
- 避免使用默认值 1
- 建议尺寸在 100 以上
- 如果较小的大小能满足需求,由于缓存局部性,在小表上使用锁有明显优势
- 检查哈希函数,并确保哈希值的低位具有良好的伪随机性
- 不应使用指针作为键
- 因为通常由于对象对齐,指针的低位中会有一定数量的零位
- 强烈建议将指针除以它所指向的类型的大小,从而移出始终为零的位,以支持变化的位
- 乘以素数,并移出一些低位,也是一个可行方案
- 如果可以避免使用 accessor 则不要使用
- 在需要 accessor 时尽可能限制其生命周期
- 因为 accessor 使用细粒度的锁,使用 accessor 可能会限制程序的扩展性
- 使用 TBB 内存分配器
- 使用方式
auto concurrent_hash_map_demo() -> void
{
struct MyHashCompare
{
static auto hash(const std::string& x) -> size_t
{
size_t h = 0;
for(const char* s = x.c_str(); *s; ++s)
{
h = (h * 17) ^ *s;
}
return h;
}
static auto equal(const std::string& x, const std::string& y) -> bool
{
return x == y;
}
};
using StringTable = tbb::concurrent_hash_map<std::string, int, MyHashCompare>;
struct Tally
{
StringTable& table;
Tally(StringTable& table)
: table{table}
{
}
auto operator()(const tbb::blocked_range<std::string*> range) const -> void
{
for(std::string* p = range.begin(); p!=range.end(); p++)
{
StringTable::accessor a;
table.insert(a, *p);
a->second += 1;
#ifdef FASTER
a.release()
#endif
}
}
};
constexpr size_t n = 10;
std::string data[n] = { "Hello", "World", "TBB", "Hello",
"So Long", "Thanks for all the fish", "So Long",
"Three", "Three", "Three" };
StringTable table;
tbb::parallel_for(tbb::blocked_range<std::string*>(data, data + n, 1000), Tally(table));
for (StringTable::iterator i = table.begin(); i != table.end(); ++i)
{
printf("%s %d\n", i->first.c_str(), i->second);
}
}
map/multimap and set/multiset
- 与 C++ 标准库 unordered_map and unordered_set 对应的 tbb 版本
- tbb 版本是线程安全的关联容器
Concurrent Queues: Regular, Bounded, and Priority
- 有两种队列
- bounding
- 对队列大小进行了限制
- 如果队列已满,则可能无法进行 push
- try to push
- concurrent_bounded_queue
- priorities
- 队列中的元素进行排序
- 默认优先级是
std::less<T>
- 也可以显示设置
std::greater<int>
- TBB 提供三种队列
- concurrent_queue
- concurrent_bounded_queue
- concurrent_priority_queue
- 三种队列都支持多线程 push 和 pop
- 无界队列不支持弹出和空测试
- 方法 try_pop 被定义为弹出一个元素(如果可用)并返回 true
- 空测试和 pop 方法被组合成单个方法以支持线程安全编码
- 还有一个非阻塞的 try_push 方法和阻塞 push 方法
- Bounding Size
- 对于 concurrent_queue 和 concurrent_priority_queue 而言,容量是无限的,受目标机器上的内存限制
- concurrent_bounded_queue
- 提供了对边界的控制
- push 方法将阻塞,直到队列有空间
- 是唯一提供 pop 方法的队列容器
- Push 方法只能通过 concurrent_bounded_queue 进行阻塞,因此,此容器类型还提供了一种称为 try_push 的非阻塞方法
- Priority Ordering
- 为了线程安全禁止使用 Top, Size, Empty, Front, Back 等方法
- Iterators
- 为什么使用并发队列: The A-B-A Problem
- 当线程检查某个位置以确保该值是 A 并仅在该值是 A 时才继续更新时,就会出现 A-B-A 问题
- 容易出现的问题是,如果其他任务以第一个任务的方式更改同一位置,是否会出现问题
- 任务未检测到:
- 任务从 globalx 读取值 A。
- 其他任务将 globalx 从 A 更改为 B,然后再更改回 A
- 步骤 1 中的任务执行 compare_and_swap,读取 A,因此不会检测到 B 的中间更改
- When to NOT Use Queues: Think Algorithms!
- 由于以下原因,使用 parallel_pipeline 或 parallel_scan 通常比队列更有效
- 队列本质上就是瓶颈,因为它们必须维持顺序
- 如果队列为空,则正在弹出值的线程将停止,直到值被推送
- 队列是一种被动的数据结构。如果线程推送一个值,则可能需要一些时间才能弹出该值,同时该值(及其引用的任何内容)在缓存中容易被淘汰
- 或者更糟糕的是,另一个线程弹出该值,并且该值(及其引用的任何内容)必须移动到另一个处理器核心
- 使用方式
tbb::concurrent_priority_queue<int> myPq;
tbb::parallel_for(0, 10001, 1, [&](size_t i)
{
myPq.push(i);
});
Concurrent Vector
- 可动态增长的 T 数组
- 有三种支持动态数组常见使用方法
- push_back
- grow_by
- grow_to_at_least
- 能够并发地增加 vector,并且能够保证元素不会在内存中移动
- 什么时候需要使用 tbb::concurrent_vector 代替 std::vector
- 与 std::vector 相比,concurrent_vector 具有更多开销
- 当需要在其他访问正在进行(或可能正在进行)时动态调整其大小
- 或者要求元素永不移动时,我们应该使用 concurrent_vector
- 元素禁止移动
- 使用少量元素作为初始大小会导致缓存行之间的碎片化
- shrink_to_fit() 将几个较小的数组合并为一个连续的数组,可能会缩短访问时间
- concurrent_vectors 的并发增长
- grow_by(n) 方法安全地附加使用 T() 初始化的 n 个连续元素
- 如果 vector 小于 n,则 grow_to_at_least(n) 将 vector 增长到 n。对 grow_by 方法的并发调用不一定按元素附加到 vector 的顺序返回
- concurrent_vectors 中的元素可能不在连续的地址上
- 只要迭代器永远不会超过 end(),concurrent_vectors 增长时使用迭代器是安全的
- 迭代器可能会引用正在进行并发构造的元素。因此,需要同步构造和访问
- 如果在 concurrent_vectors 上还有其他操作正在进行,则切勿调用 clear()
- 使用方式
tbb::concurrent_vector<int> v1{ 3, 14, 15, 92 };
tbb::parallel_for(100, 999, 1, [&](int i)
{
v1.push_back(i * 100 + 11);
v1.push_back(i * 100 + 22);
v1.push_back(i * 100 + 33);
v1.push_back(i * 100 + 44);
});
Scalable Memory Allocation
- 建议调用 std::allocate_shared 显式地使用 TBB 可扩展内存分配器
- 或通过 std::make_shared 隐式地使用 TBB 可扩展内存分配器
- 在使用 TBB 进行并行编程时,必须使用可扩展内存分配器,无论是 TBB 提供的还是其他库提供的
- 使用 TBB 编写的程序可以利用任何内存分配器解决方案
- 替代方案
- 个人理解
编译注意事项
- 使用英特尔编译器或 gcc 编译程序时,最好传入以下标志:
- -fno-builtin-malloc (on Windows: /Qfno-builtin-malloc)
- -fno-builtin-calloc (on Windows: /Qfno-builtin-calloc)
- -fno-builtin-realloc (on Windows: /Qfno-builtin-realloc)
- -fno-builtin-free (on Windows: /Qfno-builtin-free)
代理方式使用 TBB 的内存分配器
- 全局替换 new/delete 和 malloc/calloc/realloc/free/等
- 这种自动替换 malloc 和其他 C/C++ 函数进行动态内存分配的方式是迄今为止使用 TBB 可扩展内存分配器功能的最流行方式
- 可以使用 tbbmalloc_proxy 库替换 malloc/calloc/realloc/free/ 等 和 new/delete
- 将 tbbmalloc_proxy 库的头文件和 lib 引入到项目工程中,具体方法
- Linux
- 可以在程序加载时使用 LD_PRELOAD 环境变量加载代理库
- 通过将可执行文件与代理库 (-ltbbmalloc_proxy) 链接来进行替换
- Linux 程序加载器必须能够在程序加载时找到代理库和可扩展内存分配器库
- 可以将包含库的目录包含在 LD_LIBRARY_PATH 环境变量中
- 动态内存替换有两个限制
- 不支持 glibc 内存分配钩子(hock)
- 不支持 Mono
- 基于 Microsoft .NET Framework 的开源实现
- macOS
- 可以在程序加载时使用 DYLD_INSERT_LIBRARIES 环境变量加载代理库
- 通过将主可执行文件与代理库 (-ltbbmalloc_proxy) 链接来进行替换
- macOS 程序加载器必须能够在程序加载时找到代理库和可扩展内存分配器库
- 可以将包含库的目录包含在 DYLD_LIBRARY_PATH 环境变量中
- Windows
- 可以通过在源代码中添加 #include 来强制加载代理库
- 或者使用某些链接器选项
- Windows 程序加载器必须能够在程序加载时找到代理库和可扩展内存分配器库
- 将 tbbmalloc_proxy.h 包含到任何二进制文件的源中(在应用程序启动期间加载)
#include <tbb/tbbmalloc_proxy.h>
- 或将库的相关参数添加到二进制文件的链接器选项中
- 也可以为 EXE 文件或应用程序启动时加载的 DLL 指定
测试代理是否生效
- 使用大量堆栈空间时可以设置堆栈限制
- Linux/macOS
- Visual Studio
- 属性->配置属性->链接器->系统->堆栈保留大小
- “/STACK:10000000”
- 使用方式
#ifdef SM
std::shared_ptr<double> a[n];
#else
double* a[n];
#endif
auto testing_our_proxy_library_usage() -> void
{
for (int j = 0; j < 150; j++) {
#ifdef SM
tbb::parallel_for(0, n - 1, [&](int i) { a[i] = std::make_shared<double>(1.0 * i); });
#else
tbb::parallel_for(0, n - 1, [&](int i) { a[i] = new double(1.0 * i); });
#endif
tbb::parallel_for(0, n - 1, [&](int i) { *a[i] = (*a[i]) * 2.1; });
tbb::parallel_for(0, n - 1, [&](int i) { *a[i] = (*a[i]) + 3.3; });
tbb::parallel_for(0, n - 1, [&](int i) { *a[i] = (*a[i]) * 0.9; });
#ifdef SM
tbb::parallel_for(0, n - 1, [&](int i) { a[i] = nullptr; });
#else
tbb::parallel_for(0, n - 1, [&](int i) { delete a[i]; });
#endif
}
}
TBB 提供的 Allocators
- tbb_allocator
- scalable_allocator
- cached_aligned_allocator
tbb_allocator
- 如果 TBBmalloc 库可用,tbb_allocator 按照 TBB 的方式分配和释放内存
- 头文件
#include <tbb/tbb_allocator.h>
cached_aligned_allocator
- 如果 TBBmalloc 库可用 cached_aligned_allocator 按照 TBB 的方式分配和释放内存
- 既提供可扩展性,又能防止 False Sharing
- 通过确保每次分配都在单独的缓存行上完成来解决 False Sharing 问题
- 仅当 False Sharing 可能是一个真正的问题时才使用 cache_aligned_allocator
- cache_aligned_allocator 的功能会占用一些空间
- 因为它会分配多个缓存行大小的内存块
- 即使对于小对象也是如此
- 填充通常为 128 字节
- 使用 cache_aligned_allocator 分配许多小对象可能会增加内存使用量
- 尝试 tbb_allocator 和 cache_aligned_allocator 并测量特定应用程序的最终性能是一个较好的策略
- 只有当两个对象都使用 cache_aligned_allocator 分配时,才能保证防止两个对象之间出现 False Sharing
- 头文件
#include <tbb/cache_alligned_allocator.h>
scalable_allocator
- 如果 TBBmalloc 库不可用,scalable_allocator 分配内存会失败
- 随处理器数量扩展的方式分配和释放内存
- 用 scaling_allocator 代替 std::allocator 可能会提高程序性能
- scalable_allocator 分配的内存应该由 scaling_allocator 释放
- 头文件
#include <tbb/scalable_allocator.h> - 处理 true Sharing
替换 new 和 delete
- 如果想全局替换所有 new/delete 操作符,可以使用代理库
- 对于自定义需求(如果要自己重载 new/delete 方法),最常见的方法是重载特定于类的运算符,而不是全局运算符
- 替换 new 和 delete 的方法和它们的用法
- 展示了抛出和非抛出异常两个版本
- 没有重载 placement new,因为它实际上不分配内存
- 也没有实现带有对齐 (C++17) 参数的版本
- 使用方式
auto operator new(size_t size) noexcept(false) -> void*
{
if(size == 0)
{
size = 1;
}
if(void* ptr = scalable_malloc(size))
{
return ptr;
}
throw std::bad_alloc();
}
auto operator new[](size_t size) noexcept(false) -> void*
{
return operator new(size);
}
auto operator new(size_t size, const std::nothrow_t&) noexcept -> void*
{
if(size == 0)
{
size = 1;
}
if(void* ptr = scalable_malloc(size))
{
return ptr;
}
return nullptr;
}
auto operator new[] (size_t size, const std::nothrow_t&) noexcept -> void*
{
return operator new(size, std::nothrow);
}
auto operator delete(void* ptr) noexcept -> void
{
if(ptr != nullptr)
{
scalable_free(ptr);
}
}
auto operator delete[](void* ptr) noexcept -> void
{
operator delete(ptr);
}
auto operator delete(void* ptr, const std::nothrow_t&) noexcept -> void
{
if(ptr!=nullptr)
{
scalable_free(ptr);
}
}
auto operator delete[] (void* ptr, const std::nothrow_t&) noexcept -> void
{
operator delete(ptr, std::nothrow);
}
TBB 支持 Huge Page
- Huge Page
- 在大多数情况下,处理器一次分配 4K 字节的内存(通常称为页)
- 虚拟内存系统使用页表将地址映射到实际内存位置
- 无需深入探讨,只需了解应用程序使用的内存页面越多,需要的页面描述符就越多
- 为了帮助解决此类问题,现代处理器支持远大于 4K 的其他页面大小(例如 4 MB)
- 对于使用 2GB 内存的程序,需要 524,288 个页面描述来描述具有4K页面的 2GB 内存
- 使用 4 MB 描述符仅需要 512 个页面描述,如果 1 GB 描述符可用,则仅需要两个页面描述
TBB 支持
- 要在 TBB 内存分配中使用大页
- 通过调用 scalable_allocation_mode( TBBMALLOC_USE_HUGE_PAGES, 1)
- 或通过将 TBB_MALLOC_USE_HUGE_PAGES 环境变量设置为 1
- 这两种方法都假设系统/内核配置为分配大页面
- 大页面并不是万能的, 如果没有充分考虑它们的使用,它们可能会对性能产生负面影响
- scalable_allocation_mode(int mode, intptr_t value)
- TBBMALLOC_USE_HUGE_PAGES
- scalable_allocation_mode(TBBMALLOC_USE_HUGE_PAGES,1)
- 第二个参数为零将禁用它
- 使用 scalable_allocation_mode 设置的模式优先于环境变量
- TBBMALLOC_SET_SOFT_HEAP_LIMIT
- scalable_allocation_mode(TBBMALLOC_SET_SOFT_HEAP_LIMIT, size)
- 根据分配器从操作系统获取的内存量设置大小字节的阈值
- 超过阈值将促使分配器从其内部缓冲区释放内存
- int scalable_allocation_command(int cmd, void ∗param)
- 用于命令可扩展存储器分配器执行由第一参数指定的动作
- 第二个参数是保留的,必须设置为零
- TBBMALLOC_CLEAN_ALL_BUFFERS
- scalable_allocation_command(TBBMALLOC_CLEAN_ALL_BUFFERS, 0)
- 清理分配器的内部内存缓冲区, 并可能减少内存占用
- 这可能会导致后续内存分配请求的时间增加
- 该命令不适合频繁使用,建议仔细评估性能影响
- TBBMALLOC_CLEAN_THREAD_BUFFERS
- scalable_allocation_command(TBBMALLOC_CLEAN_THREAD_BUFFERS, 0)
- 清理内部内存缓冲区,但仅限于调用线程
- 这可能会导致后续内存分配请求的时间增加
- 建议仔细评估性能影响