Project

General

Profile

Actions

Feature #22483

open

Region forms of String#bit_get

Feature #22483: Region forms of String#bit_get

Added by hasumikin (hitoshi hasumi) about 4 hours ago. Updated about 4 hours ago.

Status:
Open
Assignee:
-
Target version:
-
[ruby-dev:<unknown>]

Description

Impl: (WIP)

This ticket completes the region arguments of #22279 for bit_get. #22279 added the (offset, length) and range forms to bit_set, bit_clear, bit_flip and bit_count, and the commit message of #22118 announced the same extension for bit_get. It is also the second half of my reply to the comment of Matz in #22399#note-3: where the examples of #22399 folded the Array of bits into an Integer, the region form of bit_get reads the Integer directly. The search method is proposed separately in #22482.

What this adds

String#bit_get(offset, length, lsb_first: true)  -> Integer | nil
String#bit_get(range, lsb_first: true)           -> Integer | nil

bit_get with a length or a range reads a bit region as a non-negative Integer, as Integer#[](i, len) and Integer#[](range) do for the bits of an Integer. The existing one-bit form is the case length = 1.

data = "\xF0\xAA".b
data.bit_get(4)                     # => 1
data.bit_get(0, 8)                  # => 0xF0
data.bit_get(4, 8)                  # => 0xAF
data.bit_get(8..)                   # => 0xAA
data.bit_get(0, 16)                 # => 0xAAF0

hdr = "\x12\x34\x81\x80".b             # a DNS header
hdr.bit_get(16, 16, lsb_first: false)  # => 0x8180
hdr.bit_get(4, 12, lsb_first: false)   # => 0x234

Use cases

  • Protocol headers (MSB-first). RFC diagrams number bits MSB-first and give each field a width. bit_get(offset, width, lsb_first: false) reads a field by the coordinates of the diagram, without a bit reversal and without an Array.

    hdr = "\x12\x34\x81\x80".b                      # DNS header: ID, then the 16 flag bits
    id     = hdr.bit_get(0, 16, lsb_first: false)   # => 0x1234
    qr     = hdr.bit_get(16, lsb_first: false)      # => 1
    opcode = hdr.bit_get(17, 4, lsb_first: false)   # => 0
    rcode  = hdr.bit_get(28, 4, lsb_first: false)   # => 0
    
  • Non-aligned sensor frames on a microcontroller. A telemetry format packs 12 + 10 + 14 bits into a 36-bit frame, so every second frame starts in the middle of a byte. With getbyte the decoder needs two code paths, one per alignment. With bit_get it needs one.

    frames.times do |i|
      off = i * 36
      temp = data.bit_get(off, 12)
      hum  = data.bit_get(off + 12, 10)
      co2  = data.bit_get(off + 22, 14)
      process(temp, hum, co2)
    end
    
  • Hardware registers. A device register packs several fields into one or two bytes. bit_get(5, 3) reads a 3-bit mode field as the datasheet writes it.

  • Variable-length codes. A Rice or Golomb decoder reads the k remainder bits after a unary run with bit_get(pos, k, lsb_first: false). With the search method of #22482, the decoder is two calls per value.

API Contracts

  1. Arguments follow #22118 and #22279. A negative offset raises IndexError. A negative length raises ArgumentError. A position or length beyond 2**64 - 1 raises ArgumentError. The lsb_first value must be true or false. A Range with a length raises ArgumentError, as in bit_count.
  2. The region form does not clamp. bit_count clamps a region that extends past the end, because a smaller count is still correct. A truncated value would be wrong, so bit_get returns nil instead, as the one-bit form does beyond the end and as unpack1 does on short data. The nil has one meaning, so a parser of a truncated packet can test it without a rescue. An empty region returns 0, unless it begins beyond the end: bit_get(16, 0) on a 2-byte string returns 0, and bit_get(17, 0) returns nil, as bit_get(17) does.
  3. The result has no width limit. A region wider than 64 bits returns a Bignum. In mruby, a build without mruby-bigint raises RangeError for a region wider than its Integer.
  4. lsb_first changes the bit numbering and the significance. The byte order never changes. With lsb_first: true, the first bit of the region is the least significant bit of the result, as in Integer#[](i, len). With lsb_first: false, the first bit of the region is the most significant bit of the result, which is the value that an RFC diagram shows for the field.
  5. Frozen receivers work. Encoding has no effect. The method treats the receiver as a byte sequence, as in #22118.

Why a region form of bit_get, not a new method

  • Integer#[] sets the precedent: Integer#[](i) returns one bit, and Integer#[](i, len) and Integer#[](range) return a bit region as an Integer. The region form of bit_get is the same extension for the bits of a String, with the same significance of the first bit under lsb_first: true.
  • #22279 added the same forms to the other bit methods, and the commit message of #22118 announced it for bit_get. This ticket completes it, and bit_get stays in line with the other bit methods.
  • The nil rule carries over without a new rule: a read past the end returns nil, whether it is one bit or a region.
  • No new name is needed.

A write side of the region form is not part of this ticket, because the use cases above only read. Note that bit_set(offset, length) already means "set all bits of the region", so a write of an Integer value into a region would need a name of its own, such as bit_put(offset, length, value).

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.

Benchmark script

bit_get fold unpack1 + to_i(2) getbyte + masks bit_get(offset, length)
16-bit field at offset 4, x100,000 234 ms 35 ms - 16 ms
same, lsb_first: false 297 ms 28 ms - 13 ms
233,016 frames of 12 + 10 + 14 bits 1,219 ms - 54 ms 30 ms

The region form of bit_get is faster than hand-written getbyte masks and needs no alignment branch.

Performance on PicoRuby

I ported the region form 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
  • Buffer: 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.

Benchmark script for PicoRuby

bit_get fold getbyte + masks bit_get(offset, length)
16-bit field at offset 4, x1,000 2,445 ms - 166 ms
same, lsb_first: false 2,682 ms - 160 ms
1,820 frames of 12 + 10 + 14 bits 10,130 ms 555 ms 213 ms

The ratios are the same as on CRuby, but the absolute times matter more here. The region form of bit_get is 2.6 times faster than hand-written getbyte masks and 48 times faster than a one-bit bit_get fold. 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 4 hours ago Actions #1

  • Backport deleted (3.3: UNKNOWN, 3.4: UNKNOWN, 4.0: UNKNOWN)
  • Tracker changed from Bug to Feature
Actions

Also available in: PDF Atom