From ad5d73898eeaf88d40e97612141ebcedd6d8ed02 Mon Sep 17 00:00:00 2001 From: Eugene Blikh Date: Fri, 15 May 2026 17:25:46 +0300 Subject: [PATCH] =?UTF-8?q?lazy:=20SoA=20index=20layout=20(sparse-read=200?= =?UTF-8?q?.66=C3=97=20=E2=86=92=200.90=C3=97,=20passthrough=201.5=C3=97?= =?UTF-8?q?=20=E2=86=92=201.7=C3=97)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace per-segment Lua tables with four parallel integer arrays (id, tag_start, val_start, next_start). For an emails-heavy Person at 100KB that's 4 tables of 2800 ints instead of 2800 tables of 4 keys — ~5× fewer table allocations on decode_lazy. ArrayView and MapView are similarly flattened: each holds a single int array of val_starts instead of one mini-table per element. Packed-payload expansion produces the same shape so :at(i) is one array index lookup + decode call. Before / after on bench/lazy_bench.lua (1KB / 10KB / 100KB): passthrough (decode + reencode) full: 1.43× → 1.73× 1.13× → 1.62× 1.04× → 1.69× runtime: 1.43× → 1.94× 1.15× → 1.73× 1.16× → 1.84× sparse read (:get name + :get age) full: 0.70× → 0.90× 0.65× → 0.94× 0.60× → 1.03× runtime: 0.76× → 0.99× 0.66× → 1.05× 0.68× → 1.16× rewrite name (decode + set + reencode) full: 0.98× → 1.11× 0.82× → 1.09× 0.81× → 1.14× runtime: 1.06× → 1.26× 0.95× → 1.15× 0.85× → 1.25× Sparse read goes from a loss to break-even or better; passthrough gain widens; mutate-then-reencode flips from regression to consistent win. JIT-trace gate still 19/19 — the index loop's single side-trace bridge (decode_tag at lazy.lua:49) was at decode_tag in the old code too; same pattern, different line number. Drops the unused wt field from the SoA: consumers never re-read the tag wire type after the index pass. Unknown-field splice and lazy :encode emit byte slices directly from tag_start..next_start. --- runtime/pb/lazy.lua | 384 ++++++++++++++++++++------------------------ 1 file changed, 174 insertions(+), 210 deletions(-) diff --git a/runtime/pb/lazy.lua b/runtime/pb/lazy.lua index e554cde73e3137eeb8a04561025a01c38260e1f5..a102181777b5992ae71fa21c23315b7817b628a2 100644 --- a/runtime/pb/lazy.lua +++ b/runtime/pb/lazy.lua @@ -8,13 +8,16 @@ -- The underlying Lua string is kept GC-anchored on the view table so -- substring-based passthrough remains valid for the view's lifetime. -- --- Phase 1: read-only API (:get / :has / :which / :iter / :names plus --- ArrayView :len/:at/:iter and MapView :get/:has/:keys/:iter). Mutation --- and passthrough re-encode arrive in phase 2. +-- Storage layout: SoA (struct-of-arrays). Per wire-entry we hold four +-- integer offsets in parallel arrays, not one Lua table per entry. +-- For an emails-heavy message at 100 KB that's 4 tables of 2800 ints +-- instead of 2800 tables of 4 keys — ~5× fewer table allocations on +-- decode_lazy, which is the difference between losing sparse-read 0.6× +-- and breaking even. -- -- WKT and any descriptor carrying `desc.decode` are eagerly decoded and --- wrapped in an EagerView with the same getter surface, so callers don't --- have to special-case Timestamp/Duration/Struct/etc. +-- wrapped in an EagerView with the same getter surface, so callers +-- don't have to special-case Timestamp/Duration/Struct/etc. local wire = require('pb.wire') local codec = require('pb.codec') @@ -27,72 +30,75 @@ local build_array_view local build_map_view -- --------------------------------------------------------------------------- --- Index build +-- Index build (SoA) -- --- Single pass over `bytes`. Each tag+value occupies one `segment`: --- {id = , --- tag_start = <1-based offset of tag's first byte>, --- val_start = <1-based offset of value's first byte>, --- next_start = <1-based offset just past this segment>, --- wt = } +-- Single pass over `bytes`. Returns: +-- segs = { +-- n = , +-- id = {, ...}, -- 1-based, by entry +-- tag_start = {...}, -- 1-based byte offset, tag's first byte +-- val_start = {...}, -- , value's first byte +-- next_start = {...}, -- , just past this entry +-- } +-- by_id = { [field_id] = {seg_idx, seg_idx, ...} } -- indices into segs -- --- `segments` is the wire-order list (used by iter and by passthrough re-encode --- in phase 2). `by_id` maps id -> list-of-segments, even for singular fields --- (multiple wire entries for the same singular id are legal per spec: scalar --- last-wins, message-merge — both consumers can walk the list). +-- We deliberately don't keep wire_type: consumers know it from the +-- descriptor for known fields, and we never re-decode the tag for +-- unknown fields after the index pass (we only splice their bytes). -- --------------------------------------------------------------------------- local function index_bytes(desc, bytes) local pos, lim = 1, #bytes - local segments = {} + local s_id, s_tag, s_val, s_next = {}, {}, {}, {} local by_id = {} local fbi = desc.field_by_id + local n = 0 while pos <= lim do local tag_start = pos local id, wt, npos = wire.decode_tag(bytes, pos) local val_start = npos local next_start = wire.skip_field(bytes, npos, wt) - local seg = { - id = id, - tag_start = tag_start, - val_start = val_start, - next_start = next_start, - wt = wt, - } - segments[#segments + 1] = seg + n = n + 1 + s_id[n] = id + s_tag[n] = tag_start + s_val[n] = val_start + s_next[n] = next_start if fbi[id] ~= nil then local list = by_id[id] if list == nil then - by_id[id] = {seg} + by_id[id] = {n} else - list[#list + 1] = seg + list[#list + 1] = n end end pos = next_start end - return segments, by_id + return { + n = n, + id = s_id, + tag_start = s_tag, + val_start = s_val, + next_start = s_next, + }, by_id end -- --------------------------------------------------------------------------- -- Per-field materialization -- --------------------------------------------------------------------------- --- Decode a single value from one segment, given the singular field shape. --- For message fields this returns another lazy view; for scalar/enum it --- returns the materialized Lua/cdata value. -local function read_singular(field, bytes, seg) +-- Decode a single value at `val_start`. For message fields returns a +-- lazy sub-view (or eager value via WKT desc.decode override). +local function read_singular(field, bytes, val_start) local kind = field.kind if kind == 'scalar' then - local h = wire.TYPE_INFO[field.proto_type] - local v = h.decode(bytes, seg.val_start) + local v = wire.TYPE_INFO[field.proto_type].decode(bytes, val_start) return v elseif kind == 'enum' then - local u = wire.decode_varint(bytes, seg.val_start) + local u = wire.decode_varint(bytes, val_start) return tonumber(u) elseif kind == 'message' then - local payload = wire.decode_len(bytes, seg.val_start) + local payload = wire.decode_len(bytes, val_start) if field.message.decode ~= nil then - -- WKT (or any custom-decode override). Eager. return field.message.decode(payload) end return build_msg_view(field.message, payload) @@ -102,20 +108,16 @@ end -- For singular fields, semantics for multiple wire entries with the same id: -- - scalar/enum: last wins. --- - message: merged. We delegate to the eager codec by concatenating the --- per-entry payloads and feeding them to pb.codec.decode, which honors --- proto3 merge rules. (Multi-entry singular messages are rare; this is --- the off-fast-path correctness branch.) -local function read_singular_list(field, bytes, list) - if field.kind ~= 'message' or #list == 1 then - return read_singular(field, bytes, list[#list]) - end - -- Multi-segment message: concatenate the inner payloads and eager-decode. - -- This loses the lazy sub-view but is the right thing semantically. +-- - message: merged. We delegate to the eager codec by concatenating +-- per-entry payloads. (Multi-entry singular messages are rare; this +-- is the off-fast-path correctness branch.) +local function read_singular_list(field, bytes, segs, idx_list) + if field.kind ~= 'message' or #idx_list == 1 then + return read_singular(field, bytes, segs.val_start[idx_list[#idx_list]]) + end local parts = {} - for i = 1, #list do - local seg = list[i] - local payload = wire.decode_len(bytes, seg.val_start) + for i = 1, #idx_list do + local payload = wire.decode_len(bytes, segs.val_start[idx_list[i]]) parts[i] = payload end local merged = table.concat(parts) @@ -128,110 +130,90 @@ end -- --------------------------------------------------------------------------- -- ArrayView: lazy view over a repeated field. -- --- For unpacked repeated, each wire entry is one element (one segment). --- For packed repeated (scalars/enums), one segment contains a length-delimited --- payload with all elements; we walk the payload on demand. We never --- materialize the full element array unless the user iterates it all. +-- Stores a flat int array `_starts` of value offsets (one int per element). +-- For unpacked, those come from the parent's val_start array (subset). +-- For packed, they're scanned out of the packed payload at construct time. +-- :at(i) decodes from bytes[_starts[i]] using the field's known kind. -- --------------------------------------------------------------------------- local ArrayView = {} ArrayView.__index = ArrayView --- Pre-expand packed payloads into a uniform per-element segment list at --- construct time. Cheaper to do once than to re-scan on every :at(i). -local function expand_packed(field, bytes, packed_seg) +-- Walk a packed payload, emitting one val_start per element. Cheaper as +-- a one-pass scan than re-walking on every :at — packed payloads are +-- contiguous so each step is just a wire.skip_field with the element's +-- known wire type. +local function expand_packed(field, bytes, val_start) local h = field.kind == 'scalar' and wire.TYPE_INFO[field.proto_type] or nil - -- Decode the length prefix to know where elements live. - local payload_start = packed_seg.val_start - local b = bytes:byte(payload_start) + local b = bytes:byte(val_start) local payload_len, hdr_end if b < 0x80 then - payload_len = b - hdr_end = payload_start + 1 + payload_len = b; hdr_end = val_start + 1 else - local v, npos = wire.decode_varint(bytes, payload_start) - payload_len = tonumber(v) - hdr_end = npos + local v, npos = wire.decode_varint(bytes, val_start) + payload_len = tonumber(v); hdr_end = npos end local lim = hdr_end + payload_len - local elems = {} + local elem_wire = (field.kind == 'scalar') and h.wire or wire.WIRE_VARINT + local starts, n = {}, 0 local p = hdr_end while p < lim do - local v_start = p - local v_next - if field.kind == 'scalar' then - -- Skip according to the scalar's actual wire type. - v_next = wire.skip_field(bytes, p, h.wire) - else - -- Packed enums: varint per element. - v_next = wire.skip_field(bytes, p, wire.WIRE_VARINT) - end - elems[#elems + 1] = {val_start = v_start, val_next = v_next} - p = v_next + n = n + 1 + starts[n] = p + p = wire.skip_field(bytes, p, elem_wire) end - return elems + return starts, n end -local function build_array_view_impl(field, bytes, segs) - -- For packed payloads (single segment, WIRE_LEN, but scalar field uses - -- a non-LEN wire type), elements live inside that one segment. - -- For unpacked, each segment is one element. - local mode -- 'packed' | 'unpacked' - local elements -- list of {val_start = ...} entries - if field.kind == 'scalar' then - local h = wire.TYPE_INFO[field.proto_type] - if h.wire ~= wire.WIRE_LEN and #segs == 1 and segs[1].wt == wire.WIRE_LEN then - mode = 'packed' - elements = expand_packed(field, bytes, segs[1]) - else - mode = 'unpacked' - elements = segs - end - elseif field.kind == 'enum' then - if #segs == 1 and segs[1].wt == wire.WIRE_LEN then - mode = 'packed' - elements = expand_packed(field, bytes, segs[1]) - else - mode = 'unpacked' - elements = segs - end +local function build_array_view_impl(field, bytes, segs, idx_list) + -- Detect packed vs unpacked from the wire type of the entries. + -- For repeated scalars/enums with a single LEN-typed wire entry + -- when the element type is non-LEN, that's a packed payload. + local mode, starts, n + local first_idx = idx_list[1] + local tag_byte = bytes:byte(segs.tag_start[first_idx]) + local first_wt = tag_byte % 8 -- low 3 bits + + if field.kind == 'message' then + -- Repeated messages never pack. + mode = 'unpacked' + n = #idx_list + starts = {} + for i = 1, n do starts[i] = segs.val_start[idx_list[i]] end + elseif (field.kind == 'scalar' or field.kind == 'enum') + and #idx_list == 1 and first_wt == wire.WIRE_LEN + and not (field.kind == 'scalar' + and wire.TYPE_INFO[field.proto_type].wire == wire.WIRE_LEN) then + mode = 'packed' + starts, n = expand_packed(field, bytes, segs.val_start[first_idx]) else - -- repeated message: always one segment per element, never packed. mode = 'unpacked' - elements = segs + n = #idx_list + starts = {} + for i = 1, n do starts[i] = segs.val_start[idx_list[i]] end end + return setmetatable({ - _field = field, - _bytes = bytes, - _mode = mode, - _elements = elements, + _field = field, + _bytes = bytes, + _starts = starts, + _n = n, }, ArrayView) end build_array_view = build_array_view_impl -function ArrayView:len() - return #self._elements -end +function ArrayView:len() return self._n end function ArrayView:at(i) - local entry = self._elements[i] - if entry == nil then return nil end + if i < 1 or i > self._n then return nil end local field, bytes = self._field, self._bytes - if self._mode == 'packed' then - if field.kind == 'enum' then - local u = wire.decode_varint(bytes, entry.val_start) - return tonumber(u) - end - local h = wire.TYPE_INFO[field.proto_type] - return (h.decode(bytes, entry.val_start)) - end - return read_singular(field, bytes, entry) + local val_start = self._starts[i] + return read_singular(field, bytes, val_start) end function ArrayView:iter() - local view, i = self, 0 - local n = #self._elements + local view, i, n = self, 0, self._n return function() i = i + 1 if i > n then return nil end @@ -241,34 +223,29 @@ end function ArrayView:tolist() local out = {} - for i = 1, #self._elements do out[i] = self:at(i) end + for i = 1, self._n do out[i] = self:at(i) end return out end -- --------------------------------------------------------------------------- -- MapView: lazy view over a map field. -- --- Each wire entry is one length-delimited submessage with two fields: --- id=1 (key), id=2 (value). The submessage may omit either when the --- value equals its proto3 default. --- --- For O(1) `:get(k)`, we decode keys lazily but cache the key->entry map on --- first key-lookup or first :iter. Until then, only the per-entry payload --- offsets are known. +-- Stores a flat int array `_starts` of LEN-prefix offsets — one per map +-- entry. Each entry contains the (key, value) sub-fields and is decoded +-- on first :get/:has/:iter to build a key->value cache. -- --------------------------------------------------------------------------- local MapView = {} MapView.__index = MapView -local function decode_map_entry(field, bytes, seg) +local function decode_map_entry(field, bytes, val_start) local key_field, val_field = field.key, field.value - -- Read the outer LEN to find the entry payload. - local b = bytes:byte(seg.val_start) + local b = bytes:byte(val_start) local payload_len, hdr_end if b < 0x80 then - payload_len = b; hdr_end = seg.val_start + 1 + payload_len = b; hdr_end = val_start + 1 else - local v, npos = wire.decode_varint(bytes, seg.val_start) + local v, npos = wire.decode_varint(bytes, val_start) payload_len = tonumber(v); hdr_end = npos end local lim = hdr_end + payload_len @@ -320,11 +297,15 @@ local function decode_map_entry(field, bytes, seg) return key, val end -local function build_map_view_impl(field, bytes, segs) +local function build_map_view_impl(field, bytes, segs, idx_list) + local n = #idx_list + local starts = {} + for i = 1, n do starts[i] = segs.val_start[idx_list[i]] end return setmetatable({ - _field = field, - _bytes = bytes, - _segs = segs, + _field = field, + _bytes = bytes, + _starts = starts, + _n = n, -- _by_key populated lazily on first :get/:has/:iter call. }, MapView) end @@ -333,11 +314,11 @@ build_map_view = build_map_view_impl local function map_ensure_index(self) if self._by_key ~= nil then return end - local field, bytes, segs = self._field, self._bytes, self._segs + local field, bytes, starts, n = self._field, self._bytes, self._starts, self._n local by_key = {} local keys = {} - for i = 1, #segs do - local k, v = decode_map_entry(field, bytes, segs[i]) + for i = 1, n do + local k, v = decode_map_entry(field, bytes, starts[i]) if by_key[k] == nil then keys[#keys + 1] = k end by_key[k] = v -- duplicate keys: last wins (matches eager decode) end @@ -398,11 +379,11 @@ local function build_msg_view_impl(desc, bytes) _eager_only = true, }, MessageView) end - local segments, by_id = index_bytes(desc, bytes) + local segs, by_id = index_bytes(desc, bytes) return setmetatable({ _desc = desc, _bytes = bytes, - _segments = segments, + _segs = segs, _by_id = by_id, _cache = {}, -- Parallel array of cached sub-MessageViews so :is_dirty can @@ -415,7 +396,7 @@ end build_msg_view = build_msg_view_impl M.build = build_msg_view_impl --- :get(name) -> decoded value, or nil if not on wire (and no _cache entry). +-- :get(name) -> decoded value, or nil if not on wire. function MessageView:get(name) if self._eager_only then return self._eager[name] end local cache = self._cache @@ -423,18 +404,16 @@ function MessageView:get(name) if v ~= nil then return v end local field = self._desc.field_by_name[name] if field == nil then return nil end - local segs = self._by_id[field.id] - if segs == nil then return nil end + local idx_list = self._by_id[field.id] + if idx_list == nil then return nil end if field.kind == 'map' then - v = build_map_view(field, self._bytes, segs) + v = build_map_view(field, self._bytes, self._segs, idx_list) elseif field.repeated then - v = build_array_view(field, self._bytes, segs) + v = build_array_view(field, self._bytes, self._segs, idx_list) else - v = read_singular_list(field, self._bytes, segs) + v = read_singular_list(field, self._bytes, self._segs, idx_list) end cache[name] = v - -- Track sub-MessageViews on a flat array so :is_dirty can walk - -- with ipairs (see _sub_msg_views note above). if type(v) == 'table' and getmetatable(v) == MessageView then local s = self._sub_msg_views s[#s + 1] = v @@ -452,7 +431,7 @@ end -- :which(oneof_name) -> name of the active branch, or nil. -- Proto3 last-wins: if multiple branches appeared on the wire, the one --- whose final segment came last in wire order is active. +-- whose final entry came last in wire order is active. function MessageView:which(oneof_name) if self._eager_only then local oneofs = self._desc.oneofs @@ -470,15 +449,16 @@ function MessageView:which(oneof_name) if members == nil then return nil end local member_set = {} for i = 1, #members do member_set[members[i]] = true end - local fbn = self._desc.field_by_name + local segs = self._segs + local fbi = self._desc.field_by_id local active, active_pos - local segs = self._segments - for i = 1, #segs do - local f = self._desc.field_by_id[segs[i].id] + for i = 1, segs.n do + local f = fbi[segs.id[i]] if f ~= nil and member_set[f.name] then - if active_pos == nil or segs[i].tag_start > active_pos then + local ts = segs.tag_start[i] + if active_pos == nil or ts > active_pos then active = f.name - active_pos = segs[i].tag_start + active_pos = ts end end end @@ -500,16 +480,15 @@ function MessageView:names() end end end - local segments = self._segments + local segs = self._segs local fbi = self._desc.field_by_id local emitted = {} local i = 0 return function() while true do i = i + 1 - local seg = segments[i] - if seg == nil then return nil end - local f = fbi[seg.id] + if i > segs.n then return nil end + local f = fbi[segs.id[i]] if f ~= nil and emitted[f.name] == nil then emitted[f.name] = true return f.name @@ -534,7 +513,7 @@ function MessageView:iter() end end end - local segments = self._segments + local segs = self._segs local fbi = self._desc.field_by_id local emitted = {} local view = self @@ -542,9 +521,8 @@ function MessageView:iter() return function() while true do i = i + 1 - local seg = segments[i] - if seg == nil then return nil end - local f = fbi[seg.id] + if i > segs.n then return nil end + local f = fbi[segs.id[i]] if f ~= nil and emitted[f.name] == nil then emitted[f.name] = true return f.name, view:get(f.name) @@ -559,9 +537,6 @@ end -- :set(name, value) marks a field dirty. Subsequent :encode() emits the -- new value via the codec; other fields passthrough their original bytes. --- The provided value can be any shape the eager encoder accepts (Lua --- table/string/number/cdata/array). Passing a MessageView as `value` is --- supported but materializes it via :totable() on encode. function MessageView:set(name, value) if self._eager_only then self._eager[name] = value @@ -578,14 +553,8 @@ function MessageView:set(name, value) end -- :is_dirty() returns true if this view has had :set called, OR if any --- cached sub-view (e.g. a nested MessageView accessed via :get) has been --- mutated. Used by parent :encode to decide whether to splice or re-encode. --- --- We walk _sub_msg_views (a flat array of cached sub-MessageViews) with --- ipairs instead of pairs(_cache). Hash-keyed pairs compiles to ISNEXT --- which is NYI in Tarantool's LuaJIT 2.1 — keeping is_dirty on an array --- lets the encode-time JIT trace stay attached. Array fields and map --- fields aren't recursed into: phase 2 mutation is on MessageView only. +-- cached sub-view has been mutated. Walks _sub_msg_views (flat array) +-- with ipairs to stay JIT-stable. function MessageView:is_dirty() if self._eager_only then return self._eager_dirty == true end local d = self._dirty @@ -598,7 +567,6 @@ function MessageView:is_dirty() end -- Materialize a possibly-view value so encode_field can consume it. --- Lazy sub-views are converted to plain tables; raw values pass through. local function materialize(value) if type(value) ~= 'table' then return value end local m = getmetatable(value) @@ -612,24 +580,21 @@ local function materialize(value) return value end --- Recursively materialize a value to a plain Lua structure suitable for --- the eager codec. For a MessageView, we walk every present field — --- this is the cost of mutation; pure-read messages stay lazy. function MessageView:totable() if self._eager_only then return self._eager end local out = {} for name in self:names() do - local v = self:get(name) - out[name] = materialize(v) + out[name] = materialize(self:get(name)) end -- Preserve unknown fields for round-trip. - if self._segments then + local segs = self._segs + if segs then local unknown = {} local fbi = self._desc.field_by_id - for i = 1, #self._segments do - local seg = self._segments[i] - if fbi[seg.id] == nil then - unknown[#unknown + 1] = self._bytes:sub(seg.tag_start, seg.next_start - 1) + for i = 1, segs.n do + if fbi[segs.id[i]] == nil then + unknown[#unknown + 1] = + self._bytes:sub(segs.tag_start[i], segs.next_start[i] - 1) end end if #unknown > 0 then out._unknown_fields = table.concat(unknown) end @@ -641,8 +606,8 @@ end -- 1. WKT eager-wrap: delegate to desc.encode on the materialized table. -- 2. Untouched (no dirty fields, no dirty sub-views): emit the original -- bytes verbatim — perfect byte-for-byte passthrough. --- 3. Mixed: walk fields in id order, splice clean segments, encode dirty --- values fresh. Unknown segments are emitted at the end. +-- 3. Mixed: walk fields in id order, splice clean segments, encode +-- dirty values fresh. Unknown segments are emitted at the end. function MessageView:encode() if self._eager_only then return self._desc.encode(self._eager) @@ -654,12 +619,13 @@ function MessageView:encode() local out = {} local fields = self._desc.fields local bytes = self._bytes + local segs = self._segs local by_id = self._by_id local dirty = self._dirty or {} local cache = self._cache - -- Active-oneof resolution mirrors codec.encode_message: - -- a oneof field is only emitted if it's the active branch. + -- Active-oneof resolution mirrors codec.encode_message: a oneof + -- field is only emitted if it's the active branch. local active local oneofs_list = self._desc.oneofs_list if oneofs_list then @@ -669,7 +635,8 @@ function MessageView:encode() local members = oo.members for j = 1, #members do local fname = members[j] - if dirty[fname] or by_id[self._desc.field_by_name[fname].id] then + if dirty[fname] + or by_id[self._desc.field_by_name[fname].id] then active[oo.name] = fname end end @@ -690,28 +657,25 @@ function MessageView:encode() end if f.oneof and active and active[f.oneof] ~= fname then - -- Inactive oneof branch: skip entirely. + -- Inactive oneof branch: skip. elseif is_dirty or is_sub_dirty then - local v = cache[fname] - v = materialize(v) - codec.encode_field(f, v, out, f.optional or (f.oneof ~= nil)) + codec.encode_field(f, materialize(cache[fname]), out, + f.optional or (f.oneof ~= nil)) elseif by_id[f.id] then - local segs = by_id[f.id] - for j = 1, #segs do - local seg = segs[j] - out[#out + 1] = bytes:sub(seg.tag_start, seg.next_start - 1) + local idx_list = by_id[f.id] + for j = 1, #idx_list do + local idx = idx_list[j] + out[#out + 1] = bytes:sub(segs.tag_start[idx], + segs.next_start[idx] - 1) end end end - -- Unknown segments preserved at the end (matches codec's _unknown_fields - -- trailer convention). + -- Unknown segments preserved at the end. local fbi = self._desc.field_by_id - local segments = self._segments - for i = 1, #segments do - local seg = segments[i] - if fbi[seg.id] == nil then - out[#out + 1] = bytes:sub(seg.tag_start, seg.next_start - 1) + for i = 1, segs.n do + if fbi[segs.id[i]] == nil then + out[#out + 1] = bytes:sub(segs.tag_start[i], segs.next_start[i] - 1) end end