Feature #22482
openString#bit_index, a bit search for String bit operations
Description
Impl: (WIP)
This is a reply to the comment of Matz in #22399#note-3. It replaces the seven methods of #22399 and #22405 with one search method. The deferred methods can be written on top of it in Ruby, and the last section gives the measurements on PicoRuby that Matz asked for. The region form of bit_get, which replaces the Integer fold of bits, is proposed separately in #22483.
What this adds¶
String#bit_index(bit, offset = 0, lsb_first: true) -> Integer | nil
String#bit_index(bit, offset, length, lsb_first: true) -> Integer | nil
String#bit_index(bit, range, lsb_first: true) -> Integer | nil
bit_index returns the offset of the first bit at or after offset that is equal to bit, or nil. The bit argument is 0, 1, true or false. A byte that holds no matching bit is skipped in one step. A length or a range limits the search to a window, with the region arguments of #22279.
data = "\xF0\xAA".b
data.bit_index(1) # => 4
data.bit_index(0, 4) # => 8
data.bit_index(1, lsb_first: false) # => 0
data.bit_index(1, 0...4) # => nil
"\x00".b.bit_index(1) # => nil
The deferred methods, written on top¶
bit_offsets, bit_runs and bit_run_length can be written on top of a search method in a few lines. This is the case.
def bit_offsets(s, bit)
offsets = []
i = -1
offsets << i while (i = s.bit_index(bit, i + 1))
offsets
end
def bit_run_length(s, bit, offset)
current = s.bit_get(offset)
return nil if current.nil?
return 0 if current != bit
(s.bit_index(1 - bit, offset) || s.bytesize * 8) - offset
end
def each_bit_run(s)
i = 0
size = s.bytesize * 8
while i < size
bit = s.bit_get(i)
j = s.bit_index(1 - bit, i) || size
yield bit, i, j - i
i = j
end
end
each_bit and bits are replaced by a bit_get loop. Where the examples in #22399 folded the Array into an Integer, the region form of bit_get (#22483) reads the Integer directly.
Use cases¶
-
Null positions in columnar data (LSB-first). An Apache Arrow validity bitmap marks the null elements. An Arrow array is a slice over a shared bitmap, so the window is the slice, and the padding bits are never read.
-
Block allocators. A first-fit allocation for
nblocks is the first run of cleared bits with a sufficient length. The loop allocates nothing. -
Variable-length codes (MSB-first). A Rice code stores a value as a unary run of set bits, a cleared bit, and
kremainder bits.bit_index(0, pos, lsb_first: false)finds the end of the run, and the region form ofbit_getreads the remainder.def read_rice(bs, pos, k) stop = bs.bit_index(0, pos, lsb_first: false) # the cleared bit after the run q = stop - pos r = bs.bit_get(stop + 1, k, lsb_first: false) [(q << k) | r, stop + 1 + k] end bs = "\x79\x14".b # 0111 1001 0001 0100 pos = 0 values = [] 4.times do value, pos = read_rice(bs, pos, 2) values << value end values # => [3, 9, 0, 6] -
Changed spans of an e-paper frame are the runs of set bits in
old_row.bitwise_xor(new_row), found by the loop ofeach_bit_runabove. A pending interrupt or a free block is found with one call and handled, with no loop at all.
API Contracts¶
- Arguments follow #22118 and #22279. A negative
offsetraisesIndexError. A negativelengthraisesArgumentError. A position or length beyond2**64 - 1raisesArgumentError. Thelsb_firstvalue must betrueorfalse. A Range with alengthraisesArgumentError, as inbit_count. - The
bitargument accepts0,1,trueorfalse. Any other object is converted withto_int, as for an index. An Integer other than0or1raisesArgumentError. - The result is an absolute offset in the numbering that
lsb_first:selects, sos.bit_set?(i, lsb_first: x)is true fori = s.bit_index(1, lsb_first: x). Anoffsetat or beyond the end returnsnil, likebit_get. - A window clamps. A
lengthor arangeis a window with the region rules ofbit_count: a window that extends past the end is clamped to the end of the string, as the search ofString#indexis. A window with no matching bit returnsnil, and so does an empty window. - Frozen receivers work. Encoding has no effect. The method treats the receiver as a byte sequence, as in #22118.
Scope: no iterator in this ticket¶
bit_index follows the cursor idiom of String#index. The idiom has a known cost: a scan of single bits in dense data runs the loop body once per matching bit in Ruby, and the method call bounds the loop. The measurements below show this. On CRuby the loop is 4 times slower than the bit_offsets of #22399 on a dense buffer, and on the Pico 2 W a run of 1 bit gives the search nothing to skip, so the loop is a little slower than a bit_get loop there.
This ticket targets the sparse and the run cases, which Matz named, and accepts the dense scan. I measured C iterator prototypes with a block (each_bit_index, each_bit_run): a block call costs about as much as a method call, so they gain 1.0 to 1.6 times on CRuby and 2 to 3 times on the Pico. If an iterator is wanted later, the use cases point to each_bit_run {|bit, offset, length| }, with the window forms of bit_index, and it can be added on top of the same C search. bit_run_length(bit, offset) was considered as the primitive instead of bit_index: the two carry the same information, but bit_index is the idiom of String#index and the one that each_bit_run would build on.
Performance on CRuby¶
Each time is the median of 5 calls. The machine is an AMD Ryzen 5 5600X with Ruby 4.1.0dev (master at 2db3072cfb) and this patch, gcc 13.3.0 -O3, YJIT off.
Offsets of the set bits of 1 MiB (bit_offsets written on top of bit_index):
| Sparse | Dense | |
|---|---|---|
bit_set? loop |
461 ms | 493 ms |
unpack1("b*") + index |
8.9 ms | 351 ms |
bit_index loop |
0.7 ms | 176 ms |
bit_offsets in C, no block (#22399 patch, for reference) |
1.2 ms | 41 ms |
For sparse data, the Ruby loop over bit_index is as fast as the C method of #22399. For dense data, the method call per bit bounds the loop, and the C method is 4 times faster. The loop is still faster than unpack.
Runs of 1 MiB with 1,024 runs (bit_run_length and bit_runs written on top of bit_index):
bit_get loop |
bit_index |
|
|---|---|---|
bit_run_length(1, 0) x1,000 |
358 ms | 1.0 ms |
| all 1,024 runs | 293 ms | 1.0 ms |
For long runs, bit_index gives the same speed as the C methods of #22405 (1.1 ms for the same buffer shape).
Short runs of 1 MiB with 8 Mi runs of 1 bit (the dense buffer). This is the data of the memory concern of Matz about bit_runs: one Array per run. The first row builds that Array, the second row only counts the runs.
bit_get loop |
bit_index loop |
C method of #22405, no block (for reference) | |
|---|---|---|---|
| all runs, one Array per run | 1,224 ms | 942 ms | 256 ms (bit_runs) |
| all runs, count only | 646 ms | 543 ms | 25.5 ms (each_bit_run.size) |
For short runs, bit_index gives no gain over bit_get: two method calls per run bound the loop. The Array per run costs about 400 to 600 ms in Ruby and 230 ms in C.
Performance on PicoRuby¶
I ported bit_index to the mruby VM of PicoRuby (mruby-string-bitops, which already has the methods of #22118 and #22279) and ran the same comparisons on a board.
- Board: Raspberry Pi Pico 2 W (RP2350, Cortex-M33 at 150 MHz)
- Firmware: R2P2 4.0.6, PicoRuby master at a90afd12 plus this patch, mruby VM,
MRB_INT64, release build - Buffers: 8 KiB (65,536 bits), so that every variant fits in RAM. Each time is the median of 5 runs, measured with
Machine.uptime_us.
Offsets of the set bits of 8 KiB (bit_offsets written on top of bit_index):
| Sparse | Dense | |
|---|---|---|
bit_set? loop |
2,172 ms | 2,222 ms |
bit_index loop |
4.2 ms | 1,195 ms |
Runs of 8 KiB with 8 runs (bit_run_length and bit_runs written on top of bit_index):
bit_get loop |
bit_index |
|
|---|---|---|
bit_run_length(1, 0) x10 |
2,841 ms | 3.2 ms |
| all 8 runs | 2,268 ms | 2.6 ms |
Short runs of 8 KiB with 65,536 runs of 1 bit (the dense buffer; the rows count the runs and make no Array):
bit_get loop |
bit_index loop |
|
|---|---|---|
| all 65,536 runs | 4,387 ms | 4,647 ms |
For short runs, the bit_index loop is a little slower than the bit_get loop on the microcontroller. A run of one bit gives the search in C nothing to skip, and a bit_index call costs a little more than the bit_get call it replaces.
The ratios are the same as on CRuby, but the absolute times matter more here. A bit_set? loop over 8 KiB takes 2.2 seconds on the microcontroller. The bit_index loop takes 4.2 ms for sparse data, which is the difference between a usable and an unusable bitmap scan. None of the variants allocates per element, so the heap in use did not change beyond GC noise (within 8 KiB) during any row.
Updated by hasumikin (hitoshi hasumi) about 5 hours ago
- Description updated (diff)