算法与数据结构 / Bitmask 的三副面孔 待审核
bitset · popcount / n & -n

Bitmask 的三副面孔

一个整数的二进制位在不同场景里被赋予完全不同的含义,底层却是同一个直觉:用位运算代替遍历。本页把同一个 bit 摊成三副面孔。§1 把 bit 当集合成员,用 AND、OR、XOR 直接算集合;§2 把整串 mask 当身份,这是 Rolldown 给 module 分 chunk 的做法;§3 把 bit 当 lane 占用,用 n & -n 一步选出最高优先级,这是 @vega/job 调度器与 Linux O(1) scheduler 的做法。

1 · bit 作集合成员

warm-up · bit = 集合成员

一个整数的二进制位可以当成一个集合:第 ii 位为 1 等价于元素 ii 在集合里。于是集合运算全部落到一条 CPU 指令上,交集是 AND、并集是 OR、对称差是 XOR,再用 popcount(数 1 的个数)就知道结果集合有多大。这是 bitset 的底层直觉,后两副面孔都由此延伸。

图 1-1 · 两个取自 0 到 7 的集合及其 AND、OR、XOR 结果。可点数字开关切换成员,观察三种运算的二进制位与 popcount 同步变化。

这一副面孔的典型题是 Find the Prefix Common Array,问两个数组在每个前缀里有几个共同元素。把「见过哪些数」各塞进一个 bitmask,答案就是 (a & b).bit_count(),用一条硬件指令算出交集大小,无需逐元素比对。把上面的数字换成 entry,同一张 bitmask 就成了 §2 的可达性指纹。

1.1 · 子集计数

既然第 ii 位为 1 等价于第 ii 个元素在集合里,那么一个 nn 位的整数就恰好对应一个子集:每个元素独立地有「在」与「不在」两种状态,枚举 002n12^n - 1 即走遍全部子集,共 2n2^n 个。若要求集合非空,去掉全零的那一个,得 2n12^n - 1

图 1-2 · nn 位整数与子集的一一对应。可拖动人数并逐个枚举,验证子集总数为 2n2^n、非空子集为 2n12^n - 1

枚举到这一步能看清两件事。其一,子集总数 2n2^n 不是巧合,而是 nn 个独立的二选一之积。其二,按元素个数分组求和有 k=0n(nk)=2n\sum_{k=0}^{n} \binom{n}{k} = 2^n,即二项式定理在 x=1x = 1 处的取值。n=6n = 6 时两条都给出 64,非空子集因而是 63。

2 · 整串 mask 作身份

Rolldown · bit = entry,整个 mask 当身份

打包器要把大量 module 切成若干 chunk。Rolldown 与 esbuild 的做法是给每个 entry 分配一个 bit 位,然后从每个 entry 出发做 BFS,把这个 entry 的 bit 用 OR 并进它能到达的每个 module。于是每个 module 累积出一串 bits,即「哪些 entry 能到我」的可达性指纹

关键的下一步是:bits 完全相同的 module 放进同一个 chunk。Rolldown 直接拿整个 bitset 当哈希表的 key。这不是 §1 那种数交集的用法,而是把整串 mask 当一个身份做相等聚类。

图 2-1 · 三个 entry 分占 bit 0 至 bit 2 时各 module 的指纹累积与分组。可单步执行,留意 core 与 util 都被三个 entry 可达而同为 111,因此合进同一个 shared chunk。

图 2-1 里 6 个 module 分成 5 个 chunk:指纹各异的 001011110100 各成一块,指纹同为 111 的 core 与 util 合成一个 shared chunk。相等聚类省下的是两两比较,一次哈希查表即完成分组。

注 · 与 §3 对照可以看清两种用法的分野。本节里 bit 是数据,记的是哪个 entry 可达,整串 mask 是身份,运算是 OR 传播加相等聚类。§3 的调度器里 bit 是控制位,记的是哪条 lane 有任务,运算是 n & -n 选最高优先级。同一个「用位避免扫描」的思路,两种完全不同的落法。

3 · bit 作 lane 占用

@vega/job · bit = lane 占用,n & -n 选最高优先级

@vega/job 的调度器有 6 条优先级 lane,数值越小越紧急。同 lane 内严格 FIFO,跨 lane 永远先跑数值小的。每次取下一个任务要找最高优先级的非空 lane,朴素做法是从 lane 0 扫到 lane 5。它改用位技巧:把哪条 lane 非空压成一个 6 位整数 pendingLanes,第 ii 位为 1 等价于 lane ii 有活,然后 pendingLanes & -pendingLanes 一步拿到最低置位 bit,即最高优先级的非空 lane,代价 O(1)O(1)

图 3-1 · n & -n 取最低置位 bit 的过程。可改变 pendingLanes 的位模式,逐位观察取反、加一与按位与三步如何只留下最低的那个 1。

n & -n 能留下最低的 1,是补码 n=¬n+1-n = \lnot n + 1 的直接后果。设 nn 的最低置位在第 kk 位,则第 kk 位右侧全是 0。取反后第 kk 位变 0、其右侧全变 1;再加 1 时这串 1 全部进位,结果第 kk 位重新变回 1、其右侧归零,而第 kk 位左侧的每一位都与 nn 相反。两者按位与,左侧因互补而全灭、右侧本就是 0,只剩第 kk 位。取到这个单 bit 之后,用 31 - Math.clz32(bit)(数前导零)还原成 lane 索引。

建议 · 实现见 packages/job/src/engine/scheduler/level-queue.ts 与同目录的 priority.tsLevelQueue.shift() 的三行是 bit = pendingLanes & -pendingLanesidx = laneIndex(bit)lanes[idx].shift();某条 lane 空了就 pendingLanes &= ~bit 清位,push 时 pendingLanes |= LANE_BITS[priority],而 LANE_BITS[i]1 << i。取最高优先级、判空、加入全是 O(1)O(1) 位操作,从不遍历 6 条 lane。这套 lane 编号与位序的对应不能反:只有让 lane 0 占最低位,n & -n 取到的才是最紧急那条。

三副面孔到此收束:§1 里 bit 是集合成员,用 AND、OR、XOR 算集合;§2 里 bit 是 entry、整串 mask 当身份做分组;§3 里 bit 是 lane 占用,用 n & -n 做优先级选择。最后一副与 Linux O(1) scheduler 的优先级 bitmap、React 的 lane model 同源,而与 Rolldown 的 code splitting 只是共用了位运算这层直觉。

注 · 本页的缘起是一个提问:@vega/job 调度器里那个 bitmask,是不是和 Rolldown code splitting 同一套做法。答案是同一个 bit 直觉、两种不同用法,前者是 OR 传播加相等聚类,后者是最低置位选择,本页把这个对照展开成三节。

相关链接