FCbO并行化实战:用CUDA将概念格构造提速44倍
概念格在形式概念分析里是个老话题但一到工程落地就尴尬理论优雅计算要命。对象集几百、属性集几十的小规模数据还好说一旦对象过万、属性过千概念数量随时可以冲到百万级甚至千万级所有闭包运算、canonical去重判断都必须在一遍遍的集合遍历里完成。标题里的FCBO在形式概念分析社区里更常见的写法是FCbOFast Close-by-One去年我在一个离线计算任务里需要用FCbO构造数万对象的概念格CPU单线程跑一次要两三个小时试了很多工程优化也就是把常数项压下去复杂度上不去。后来决定用CUDA做并行化改造核心算法不动只把FCbO的递归树搬到GPU上。前后折腾了三版最后把端到端时间压到了十几分钟量级。这篇文章就把整个改造过程、内核设计思路和踩过的坑原原本本讲一遍给准备碰这个概念格并行化的人做个参考。1. 先搞清楚FCbO到底在算什么形式概念与格结构1.1 形式上下文与“概念”的定义形式上下文可以看成一张0/1矩阵行是对象列是属性单元格为1表示该对象拥有该属性。对任意对象子集XX表示X中所有对象共同拥有的属性集合对任意属性子集YY表示拥有Y中所有属性的对象集合。形式概念就是满足X Y且Y X的一对(X, Y)前者叫外延extent后者叫内涵intent。这个对偶算子构成加洛连接反复作用就能得到闭包闭包算子就是构造概念的基础。这个概念看起来抽象其实生活里到处都是。比如电商平台的商品可以看成对象商品的标签看成属性“所有包含‘运动’和‘户外’标签的商品”是属性集Y对应的对象集Y而这批对象的共同标签又可能恰好等于Y那(Y, Y)就是一个概念。概念格就是把所有这种稳定的“对象—属性”组合组织成一个偏序结构便于我们去理解数据里的规则。1.2 格结构、蕴含规则与闭包算子概念格上的偏序是外延包含如果一个概念的外延包含另一个概念的外延那么它就是“更大”的概念内涵反向包含。所有概念按这种包含关系构成一个完备格。这个格揭示了属性之间的蕴含规则如果某个概念的每个对象都具有某属性那么其他概念的外延被包含时这些属性也自动成立。FCA最重要的产出就是这个格。实际写代码时最常用的核心操作是三个从属性集到对象集的交运算、从对象集到属性集的交运算、以及在此基础上做闭包。这三个操作全部可以转成位图的AND。外延是对象位图内涵是属性位图求“拥有属性Y的所有对象”就是把这些属性的列位图逐位AND起来剩余位为1的对象就是Y。从extent求intent同理把所有对象的行位图AND起来。1.3 复杂度为什么让人头疼一个完备概念格节点数可以大到2^min(|G|, |M|)量级最坏情况下所有子集都是闭集。现实数据普遍稀疏闭包少概念数通常远小于上限但百万级非常常见。FCbO这类闭包枚举算法本质上是在做深度优先搜索把每个闭集都精确地访问一次。缺点是递归深、节点粒度差异大、中间状态多单线程跑大上下文时时间几乎都耗在重复的位图遍历上。并行化的诱惑力正在这里每个节点的闭包运算是独立的位运算天然可以分块。难点在于如何把递归DFS改造成GPU友好的动态任务模型。2. 串行递归的拆解为什么FCbO天生适合并行又不适合GPU2.1 CbO递归主循环CbO算法是FCbO的基础。它按属性下标从小到大扩展。当前节点记为(A, B)A是外延对象集B是内涵属性集。对于每个大于当前游标j的属性j若j不在B中则计算C A ∩ j也就是在A的基础上筛掉不拥有属性j的对象。然后计算D C即C的公共属性闭包。如果D在属性0到j-1这一段前缀上的位和B完全相同就递归进入(C, D, j)这个子节点否则跳过。为什么要求前缀一致这是CbO的关键。加入属性j后内涵发生了变化这个变化的第一个位置必须是j本身而不是更小的某个属性。如果D在j之前的某个位k上也变化了说明存在另一条“更早”的路径从加入属性k开始也能到达同一个概念当前路径就不是规范路径直接剪掉。这就是canonical test它保证了遍历过程中既不会重复生成概念也不会漏掉任何一个闭集。2.2 canonical test让并行化有了理论保障canonical test最重要的性质是判断依据完全基于当前节点的局部状态(A, B)和候选属性j不需要任何全局历史信息。这意味着在并行环境下每个线程拿到一个任务后都可以独立判断“这个子概念是否该由我生成”不需要和其他线程协商。它相当于给每个概念指定了唯一一条规范生成路径只要遵守这条路径并行执行的结果和串行执行在集合层面完全一致。我在工程里特别喜欢这个性质。很多递归算法没法并行到节点级因为去重依赖共享的全局哈希表。FCbO的canonical判断把这个全局依赖消掉了不同线程可以自由前往搜索树的任意分支只要每个节点都做同样的前缀比较最后得到的“概念集合”就是同一个。这个性质是整个CUDA实现能成立的理论基石后面所有调度设计都在围绕它展开。2.3 FCbO的两级剪枝属性饱和跳过FCbO在CbO基础上加了剪枝。一个有效的剪枝是如果当前外延A中的每个对象都已经拥有属性j即A ⊆ j那么添加j之后C A外延不变。这时候无论怎么递归都不可能产生新的外延集合只会把j吸收进内涵里。对这种“属性饱和”的情况FCbO的处理是合并进当前任务的内涵后直接跳过不再单独生成子节点。这个优化在数据上效果很猛。假设有一个高频属性比如商品上下文里“可售”这样的属性基本90%的对象都有它。克塞内层递归时每次都要对这样一个属性做列交集和canonical判断实际上这个属性根本不可能筛出新对象。剪掉之后搜索树的分支宽度会大幅收窄。我在单线程基线里也实现了这个优化发现它能把计算时间压掉一半以上。GPU并行时任务总量减少调度开销也随之下降收益更明显。2.4 递归DFS的“GPU不适症”把CbO直接照搬到GPU是不现实的。首先CUDA内核不支持深递归递归栈深度不受控编译器能把很有限的迭代展开再深就只能崩。其次DFS天然会产生深度不均匀的搜索树有的分支一路深挖几十层有的分支第一层就到底如果每个block固定占用一个分支会出现严重的负载不均衡。第三串行DFS的递归现场是一套调用栈栈上随时保存着每个祖先节点的位图而GPU上并没有一个便宜的“栈”可以放几千个上下文。所以我的做法是先把递归改成显式的任务队列再让GPU线程池动态消费任务。每个任务对应搜索树上的一个节点携带完整外延位图、内涵位图和下一个可扩展属性游标。任务之间互相独立canonical test保证去重语义GPU的多个block各自取任务执行即可。这个改造有点像把深度优先遍历改成广度优先的“工作窃取”模型代价是任务携带的位图状态需要一块显存来装这个权衡后面会细说。3. 并行方案设计位图化、任务池与双通道闭包3.1 位图编码与双向矩阵布局GPU上做集合运算位图是最自然的载体。外延用N位位图N是对象数内涵用M位位图M是属性数。假设N5万M400那么一个概念的外延位图是6.25KB内涵位图是50字节。位图运算可以按32位字并行一个warp正好能处理一个位图word的多个对象用__ballot和__reduce_and_sync做归约非常方便。内存布局要特别小心。闭包运算存在两个方向从extent到intent需要按行取对象属性从intent到extent需要按列取属性位图。如果只存一份行优先矩阵算j时要频繁跳跃访问访存局部性极差。所以我存了两份副本一份行优先位图ctxRows用于从对象集计算公共属性一份列优先位图ctxCols用于从候选属性计算对象交集。两份副本在主机端一次性生成后拷进显存之后整个构造过程不再改动。3.2 从DFS到动态任务池任务池的模型是这样的全局有一个大数组保存所有待处理任务每个任务的结构是外延位图内涵位图nextAttr游标父概念ID。GPU上每个block循环执行从任务池取一个任务处理完当前节点的闭包、canonical判断、子任务生成把新任务追加回任务池。这个模型有两个关键实现要点。第一是入池出池要用原子操作维护head指针任务槽用atomicAdd来分配避免多个block写同一个位置。第二是每个block不应该只取一个任务跑完再取下一个那样调度粒度太粗。我采用批处理block每次从全局池取一批任务放到共享内存的队列里逐条执行执行过程中产生的新任务先攒在共享内存的buffer里攒到一定数量再一次写回全局。批大小要按任务大小和block内线程数调整我在实现里试过2、4、8、16最后8个任务一批效果最好。3.3 两级并行任务级并行与位运算级并行并行性来自两个层面。任务级并行不同block处理搜索树不同分支这是最粗粒度并行。数据级并行单个节点内部的位图运算由block内多个线程按word维度并行。比如计算闭包时M/32个intent word可以分配给block内的多个线程计算extent∩j时N/32个extent word也可以拆开。这里有个工程取舍一个block同时处理多个任务还是让一个任务占满整个block如果数据级维度太小比如M256I_WORDS只有8一个任务只占用8个线程其余线程空转。所以我选择让block内同时处理多个任务每个任务用少量线程做位图运算。这样block的占用率高而且任务之间天然掩蔽了访存延迟。具体到我的实现里block size256同时处理8个任务每个任务分32个线程一个warp恰好对应一个intent word的归约或一个extent word块的扫描。3.4 显存复用与任务大小控制任务携带完整位图副本最怕显存炸。任务池的总内存取决于三个因素池容量、extent位图大小、intent位图大小。N5万、M400时单个任务约6.3KB如果池容量开到20万就要1.2GB加上概念结果池和其他副本显存压力不小。几个实用控制手段第一任务池不能静态开到最大用动态增长策略满了就申请下一块避免一上来占用大块显存。第二任务执行完成后立即归还位图缓冲区用自由列表复用。第三外延位图很大的时候子任务只记录“相对父任务的增量”也就是保存父任务ID加一个列掩码等到真正需要闭包计算时再临时恢复完整外延。这个写时复制优化能显著降低任务池内存峰值代价是多一次位图重建。我在第一版没做结果N10万的数据上任务池直接吃掉7GB显存后来加上COW才压下来。如果只是跑中小规模数据直接复制更省心COW可以作为后续优化点。4. CUDA内核的逐步实现与关键代码4.1 任务队列与调度主循环内核入口的调度骨架大致如下。每个block维护一个共享队列先从全局任务池批量取任务再循环处理。子任务先缓存在共享内存攒够BATCH_SIZE后一次性写回全局池减少全局原子操作次数。#define BATCH_SIZE 8 __global__ void fcbo_kernel( const Context ctx, TaskPool pool, ConceptPool conc) { __shared__ Task s_queue[BATCH_SIZE * 8]; __shared__ int s_head, s_tail; __shared__ Task s_batch[BATCH_SIZE]; __shared__ int s_batchCount; if (threadIdx.x 0) { s_head 0; s_tail 0; s_batchCount 0; } __syncthreads(); while (true) { Task cur; bool has false; if (threadIdx.x 0) { // 从共享队列取一个任务 if (s_head s_tail) { cur s_queue[s_head]; has true; } else { // 批量从全局池取 int begin atomicAdd(pool.head, BATCH_SIZE); if (begin pool.size) { int end min(begin BATCH_SIZE, pool.size); for (int t threadIdx.x; t end - begin; t blockDim.x) { s_batch[t] pool.tasks[begin t]; } s_batchCount end - begin; __threadfence(); s_head 0; s_tail s_batchCount; if (s_tail 0) { cur s_queue[s_head]; has true; } } } } __syncthreads(); if (!has) break; int parentId cur.parent; // 处理当前节点写概念结果、扩展子任务 process_node(ctx, pool, conc, cur, parentId); } }这个写法只是示意实际还有pool任务槽位的回收和共享队列写回逻辑。核心思想是让少量线程负责调度大部分线程执行process_node里的位图运算避免调度逻辑和计算逻辑互相拖累。4.2 闭包计算内核从extent到intent的并行归约闭包计算是每个节点最贵的操作。从extent求intent时intent的每个word可以由一个线程独立计算。每个线程扫描extent中所有为1的对象取出对象行位图对应word做AND归约。因为intent word数量可能很少我会把对象扫描范围拆成多个chunk让多个warp合作计算同一个word再用共享内存做跨warp归约。__device__ unsigned int closure_one_word( const unsigned int* ctxRows, const unsigned int* extent, int wordIdx, int lane, int warpCount, int warpSize) { // 每个warp负责intent的第wordIdx个word unsigned int acc 0xFFFFFFFFu; int objWords ctx.numObjects / 32; int chunk (objWords warpCount - 1) / warpCount; int begin warpId * chunk, end min(begin chunk, objWords); for (int ow begin lane; ow end; ow warpSize) { unsigned int bits extent[ow]; while (bits) { int bit __ffs(bits) - 1; bits bits - 1; int objId ow * 32 bit; acc ctxRows[(size_t)objId * ctx.intentWords wordIdx]; } } acc __reduce_and_sync(0xFFFFFFFFu, acc); return acc; }从这个代码能看出两个优化点。一是extent中0的word用continue跳过实测很多概念外延稀疏跳过能省大量访存。二是对象行的访问是连续的同一warp内的lane在连续对象范围里取同word合并到少数几个内存事务里带宽利用相对好。从intent到extent的候选扩展计算更简单就是外延位图和属性列位图逐wordAND可以用一个完全合并的循环实现。4.3 canonical测试内核与前缀掩码canonical测试的核心逻辑是判断D和B在0到j-1的前缀上是否一致。属性数M不大时可以单线程做M上千以后可以按word并行但前缀比较的依赖是顺序的所以实际上用一个线程循环就足够。__device__ bool canonical_ok( const unsigned int* B, const unsigned int* D, int attrIndex) { int word attrIndex 5; int bit attrIndex 31; for (int i 0; i word; i) { if (B[i] ! D[i]) return false; } unsigned int prefixMask bit ? ((1u bit) - 1u) : 0u; return ((B[word] prefixMask) (D[word] prefixMask)); }注意掩码只覆盖前缀0到j-1不包含第j位本身。因为D必然包含新加入的属性j而B不一定比较第j位会错误地拒绝所有合法子任务。我第一次实现就栽在这个细节上排查时浪费了大半天。4.4 概念结果写入与格边的离线重建每个节点通过canonical判断后就会被确认为一个新概念。并行环境下多个block会同时命中新概念因此用atomicAdd分配概念ID然后把extent和intent写入概念池对应槽位。int newId atomicAdd(conc.count, 1); unsigned int* eptr conc.extents (size_t)newId * ctx.extentWords; unsigned int* iptr conc.intents (size_t)newId * ctx.intentWords; for (int w threadIdx.x; w ctx.extentWords; w blockDim.x) eptr[w] cur.extent[w]; for (int w threadIdx.x; w ctx.intentWords; w blockDim.x) iptr[w] cur.intent[w];这样得到的ID只能保证唯一不能保证ID顺序和格的偏序对应。如果只需要枚举概念集合和内涵/外延数据到此就结束了。需要完整概念格边覆盖关系时我会在GPU构造完成后把概念池拷回主机在CPU上按外延包含关系做一次排序和相邻检查离线重建偏序。原因是并行生成概念时父边并不能直接等于格覆盖边——生成树边和格覆盖边是两回事强行在线维护需要全局同步的锁代价太大。5. 实测数据与调优记录加速比、负载均衡与访存陷阱5.1 测试环境与基准设定我的测试机器是64GB内存加一块24GB显存的消费级旗舰卡CPU端基线是单线程C实现同样用位图、位运算而不是朴素数组算是打过优化的基线。测试数据用了两个匿名数据集A是电商浏览上下文对象6.8万属性412密度0.11概念数约120万B是软件配置特征矩阵对象1920属性96密度0.34概念数约3万。数据集对象数属性数密度概念数CPU单线程CUDA首版优化后A68,0004120.111,200,0004280秒480秒96秒B1,920960.3431,00037秒11秒8.4秒优化后A的加速比接近44倍B只有4.4倍。差距主要来自B的数据规模太小调度和拷贝开销占比高位图运算本身的并行收益被摊薄了。这也是一个很重要的边界信号不是所有数据都适合GPU。5.2 瓶颈拆解首版为什么只快9倍首版实现把每个任务直接绑定给一个blockblock内所有线程都参与该任务的位图运算。结果A数据集上只加速9倍左右远低于预期。分析发现GPU利用率只有41%大量时间花在空转等待任务上。原因有三任务粒度太大根节点附近的任务处理几十秒其他block都闲着全局原子操作太频繁每个子任务入池都要抢一次计数器叶子任务太轻一个任务可能只算一个word就结束但一个block的资源被占用88微秒才释放。后来改成批量取任务和共享内存小队列GPU利用率提升到79%。A的480秒降到96秒说明调度开销和负载均衡问题比位图运算本身更能决定最终性能。5.3 负载均衡失控的教训递归树的不平衡是FCbO并行化最大的工程障碍。靠近根节点的大任务extent可能有几万个对象闭包计算要扫描几万个对象行而叶子任务往往只有一个对象或一个属性一个小warp就能算完。如果任务分配按入池顺序来大任务先被几个block抢走剩下的block反复取到小任务很快全部空转。优化手段有三个层次一是批量取任务时按任务预估负载排序估算方式可以粗糙地按对象位数计算二是限制大任务不拆得太细extent对象数小于64的任务直接由当前block顺序处理不再入池三是采用工作窃取思想block的共享队列空了才去全局池取让全局池只兜底。5.4 bank conflict与位图访存调优位图运算看起来都是连续访存但共享内存里的bank conflict很容易被忽略。任务队列批量复制位图时如果每个线程按顺序复制intent wordThread0和Thread1访问相距n个word的地址当n不是32倍数时会均匀散列到bank上冲突反而少问题出在循环步长恰好等于32的边界情况会出现同一bank被重复访问。我的做法是把共享内存里的任务块按stride 33排列避免conflict。全局内存上对象行位图按32位word访问一个warp内连续lane访问同一个对象行里连续word时已经是合并访问不需要额外处理。5.5 分支发散控制把发散范围限制在位图层canonical判断和属性饱和检查天然带分支不同任务可扩展的属性数量差异极大同一个warp内线程很容易各走各的路径。一个有效策略是把任务内扩展属性的循环放在单线程里执行位图运算仍然分给warp这样分支发散只会影响控制逻辑不会拖累位图运算吞吐。另一个策略是调整block任务分配让同一warp尽量承接属性数量相近的任务实测能减少15%到20%的运行时间。6. 工程化避坑指南显存、确定性输出与适用边界6.1 显存估算与动态扩容做CUDA版之前先粗略估算概念数K每个概念位图大小E_WORDSI_WORDS字4字节每字那么概念结果池就是K×(E_WORDSI_WORDS)×4字节。任务池容量T的话再乘T份位图。以N10万、M1000为例单个概念约13.75KB如果预期K50万光概念池就要7GB加上任务池、矩阵副本很容易逼近显存上限。所以任务池必须支持动态扩容或者用写时复制方案降低单任务占用。提示显存不足时不要轻易尝试把所有概念结果一次性放显存。可以先用一个小的任务池跑起来再用cudaMemcpy把已生成的概念批量搬回主机端把GPU释放出来继续计算。搬移和计算要错开否则会互相阻塞。6.2 输出确定性与正确性验证并行执行时概念ID分配顺序不稳定两次运行结果数组顺序可能完全不一样。为了可验证性我在每个概念生成时额外记录了intent的字典序key跑完再排序保证最终输出稳定。验证环节做了三件事概念总数必须和单线程版本完全一致随机抽若干概念检查闭包条件XY和YX把所有概念哈希后查重。并行实现里最常见的bug就是漏概念和重复概念而且往往只在特定数据密度下才暴露。A数据集上排查过一版漏概念的问题根源是canonical mask没有对齐到属性边界导致边界属性漏判。6.3 和CPU多线程方案的取舍同样的数据我用某颗多核CPU上的16线程版本做了对比A跑了约310秒比单线程快13倍但CUDA优化后的96秒仍快不少。CPU多线程版本的优势是内存上限高概念数到百万甚至千万级都不慌GPU版本的优势是位图归约做得快适合频繁且密集的位运算。如果目标只是“能跑出来”CPU多线程更稳妥调试成本低如果目标是把数十万对象级的小规模上下文反复构造CUDA的几何加速才值得投入。两者还可以混合CPU层先按属性前缀粗分任务GPU层对每个子任务做细粒度并行这是后续值得扩展的方向。6.4 到底什么样的数据适合这套实现基于几个月的使用体验我给这套CUDA实现划了一条经验边界属性数M最好在几十到两千以内太大intent位图就涨起来了闭包运算的访存压力会抵消并行收益对象数N在几千到几十万级别最适合太小不如CPU多线程太大单个概念位图就过于庞大数据密度不能太高稠密数据下概念数量接近理论上限显存和计算量都会先爆掉。如果业务场景只是频繁项集挖掘不需要完整格那就没必要上概念格构造概念格的独特价值是同时拿到对象集和属性集的双向闭包这个优势需要付出构造代价预算要事先算清楚。我个人的体会是FCbO这类算法最迷人的地方不是它速度快而是canonical机制的局部性非常好它把“全局去重”变成了“本地判断”所以并行化时不需要引入复杂的全局同步表。只要把这个性质用好剩下的工作就是数据布局和调度。当然真去实现一遍还是比预想多花了一个多月——中间我推翻过一版用全局锁保护概念池的实现后来又彻底重写了任务调度才拿到可用的加速比。如果你想把概念格的规模再往上推我建议走“CPU层先粗分、GPU层细算”的混合路线或者用任务分配器把多块GPU串起来这个方向我觉得还有不少可玩的地方。