Project

General

Profile

Actions

Feature #22431

open

Add `IO#unpack` and `IO#unpack1`

Feature #22431: Add `IO#unpack` and `IO#unpack1`

Added by ntkme (Natsuki Natsume) about 8 hours ago. Updated 30 minutes ago.

Status:
Open
Assignee:
-
Target version:
-
[ruby-core:127083]

Description

I would like to have ruby add IO#unpack and IO#unpack1 that effectively does both IO#read(...) and String#unpacking(...) in one call.

This can significantly improve the performance of consuming data in a variable-length encoding format (E.g. LEB128) from a non-peekable IO stream (e.g. network/stdio).

In a streaming IO with variable-length encoding, today it requires the consumer to scan the stream byte-by-byte to precisely finding the ending of a variable-length segment (E.g. LEB128). If we can have IO#unpack/IO#unpack1, it will be able to precisely consume a variable-length segment by itself, without needing a two-step solution that requires lots of intermediate ruby object allocation.


Here is a read world example:

LEB128 pack/unpack was recently added by: https://bugs.ruby-lang.org/issues/21785

A very common use case for this is LEB128 delimited payload streaming. In real world it can be a LEB128 delimited protobuf message stream.

For example:

length, payload, length, payload, length, payload...

In pseudo code, reading and writing such stream would look like:

def write(payload, io)
  io.write([payload.length].pack('R'))
  io.write(payload)
end

def read(io)
  length = io.unpack1('R')
            # ^ This method does not exist, which makes the real world implementation slower and more complicated.
            #   See attached benchmark.rb for working implementations.
  io.read(length)
end

See attached benchmark.rb, which demonstrates the limitation of native unpacking for LEB128:

ruby 4.1.0dev (2026-10-12T17:01:34Z master 98fd04f76c) +YJIT +PRISM [arm64-darwin27]
Warming up --------------------------------------
write varint (pure ruby)    12.167k i/100ms
write leb128 (native)       15.710k i/100ms
Calculating -------------------------------------
write varint (pure ruby)    124.060k (± 1.6%) i/s    (8.06 μs/i) -    620.517k in   5.001746s
write leb128 (native)       157.036k (± 1.1%) i/s    (6.37 μs/i) -    785.500k in   5.002040s

Comparison:
write leb128 (native)   :   157035.9 i/s
write varint (pure ruby):   124060.1 i/s - 1.27x  slower

ruby 4.1.0dev (2026-10-12T17:01:34Z master 98fd04f76c) +YJIT +PRISM [arm64-darwin27]
Warming up --------------------------------------
read varint (pure ruby)    42.025k i/100ms
read leb128 (native)       20.246k i/100ms
Calculating -------------------------------------
read varint (pure ruby)    423.775k (± 0.8%) i/s    (2.36 μs/i) -      2.143M in   5.057579s
read leb128 (native)       201.781k (± 1.4%) i/s    (4.96 μs/i) -      1.012M in   5.016818s

Comparison:
read varint (pure ruby):   423774.9 i/s
read leb128 (native)   :   201781.3 i/s - 2.10x  slower

On the writing path, LEB128 packing outperforms the pure ruby implementation, which is expected.

However, on the reading path, LEB128 unpacking does not help the performance due to the "variable length" nature of LEB128, which requires finding exact end of the LEB128 before it can be unpacked:

buffer = []
loop do
  byte = io.readbyte
  buffer << byte
  break if byte < 0x80 # this is the last byte of a LEB128 number
end
buffer.pack('C*').unpack1('R')

Because of the extra intermediate steps that locate the ending of LEB128 and build a buffer, using LEB128 unpacking in a streaming context ended up to be much slower than a pure ruby implementation that does both scanning and decoding in a single loop:

value = bits = 0
loop do
  byte = io.readbyte
  value |= (byte & 0x7f) << bits
  bits += 7
  break if byte < 0x80
end
value

Therefore, if we can have a native ruby method that does scanning and decoding from IO in one shot, it would be a huge improvement.

io.unpack1('R')

Files

benchmark.rb (1.64 KB) benchmark.rb ntkme (Natsuki Natsume), 10/12/2026 10:31 PM
Actions

Also available in: PDF Atom