leptris 1.9.33 → 1.9.34

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: '01791cd8ecc5e2c7b472ac0333ae89ab338d6fa8872a55f724cafe708f866f2b'
4
- data.tar.gz: 00d0115424789446417bce8b22d35a8b1f23883c17dd135f4a4e8d0d8ba19aa1
3
+ metadata.gz: f0445600493fa886524c162f270e55bccb3550d2c8b9463fe0a7ce305687a7ae
4
+ data.tar.gz: 878c7ecdcb828d80e3a25c0a05932113ce33030ccf163aea3896453fa0fc0bb8
5
5
  SHA512:
6
- metadata.gz: a8d17f3f61f0317d9165a57e6ecf134cb22751c23f5fd39a669e9df7f6fa414e780a52ab55f7bd549c1ea10ae8cfa6d0fad6fe44f9b092ac581974d391cfa521
7
- data.tar.gz: 2842e82675cba9aad3bc4bb859e1a85a78043b3b81b4eebf2d7c0afab4df3abacb01ddc56144d0bec96bd3473d5ad31bff9dc4beef31c2639282c9f7c55b84f1
6
+ metadata.gz: e9ab5ca5b140e729066e27a1afa5882639c7e18b6a10a94be06eccca5723ce0bf4547dd70940f367277548802caaca4a76d874148153fd55131ba5bb1d20aeb2
7
+ data.tar.gz: c8c62d53ff231fb9eedf7f8aa5a3d96774517bf26b347cc4b833ee8d149d9c80a37e014b1f4833bd2a7074eae4944d13819d89c292c8a0ccf0ead4b147101ff2
data/CHANGELOG.md CHANGED
@@ -5,6 +5,37 @@ All notable changes to Leptris will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [1.9.34] - 2026-08-28
9
+
10
+ ### Changed
11
+
12
+ - **SAX fully wins every shape** (leptris-ruby's parity-not-win,
13
+ upstream #594's framing): on the all-events handler profile the
14
+ binding previously LOST to Nokogiri by 14% (170 vs 149 ms per
15
+ 1.9 MB parse) — the ffi gem's per-event callback trampoline
16
+ could not match a compiled C extension. Two things changed:
17
+ - The engine's C-side recorder cost no longer scales with
18
+ element count (~12 ms per 250k-event document now), so a bulk
19
+ transport can win.
20
+ - New `SAX::Recorder#dispatch(handler, kinds)` — the two-level
21
+ drain driving handler METHOD calls with the callback
22
+ transport's exact shapes (pairs arrays, one-arg arity
23
+ dispatch, UTF-8, PI normalization). `SAX::Parser` picks the
24
+ transport by override weight (characters 0.6, start/end 0.2;
25
+ ≥ 0.8 → bulk): one overridden hot kind stays on callbacks
26
+ (the engine skips C-side emission for unattached kinds:
27
+ text-only 23 ms vs 43 ms), two or more go bulk (start+end+chars
28
+ 167 → **122 ms**, ahead of Nokogiri's 142 ms on the same
29
+ machine; head-to-head 123 vs 133). Handlers cannot tell which
30
+ transport served them — spec-pinned byte-identical call
31
+ sequences.
32
+ - Drain repairs that made it possible: kind-strip two-level
33
+ drain (no more 2.25M-Integer unpack array per single-feed
34
+ drain), `FFI::Struct.size` hoisted out of the loop (250k
35
+ layout lookups per parse), unused fields yield nil instead of
36
+ empty-String allocations, a characters fast path in the drain
37
+ loop.
38
+
8
39
  ## [1.9.33] - 2026-08-28
9
40
 
10
41
  ### Changed
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Leptris
4
- VERSION = "1.9.33"
4
+ VERSION = "1.9.34"
5
5
  end
@@ -18,6 +18,7 @@ class Leptris::XML::SAX::Parser
18
18
  def document=(handler)
19
19
  @document = handler
20
20
  @handler_struct = nil
21
+ @dispatched_kinds = nil
21
22
  end
22
23
 
23
24
  # The handler struct (eleven FFI::Function callbacks + the C
@@ -40,16 +41,97 @@ class Leptris::XML::SAX::Parser
40
41
 
41
42
  def parse_memory(string)
42
43
  string = string.dup.force_encoding("UTF-8")
43
- handler_struct = self.handler_struct
44
- rc = Leptris::XML::FFI.leptris_sax_parse(
45
- string, string.bytesize, handler_struct.pointer, nil)
46
- if rc != 0
47
- raise Leptris::XML::ParseError,
48
- "leptris_sax_parse failed (rc=#{rc})"
44
+ if bulk_dispatch?
45
+ parse_memory_bulk(string)
46
+ else
47
+ handler_struct = self.handler_struct
48
+ rc = Leptris::XML::FFI.leptris_sax_parse(
49
+ string, string.bytesize, handler_struct.pointer, nil)
50
+ if rc != 0
51
+ raise Leptris::XML::ParseError,
52
+ "leptris_sax_parse failed (rc=#{rc})"
53
+ end
49
54
  end
50
55
  self
51
56
  end
52
57
 
58
+ # Bulk transport for many-override handlers: record the whole
59
+ # document C-side (~12ms per 250k events), then dispatch through
60
+ # the recorder's drain — 250k ffi callback trampolines cost
61
+ # ~150ms, so this wins outright for handlers that consume most of
62
+ # the event stream (and beats Nokogiri's C extension on the same
63
+ # shape). The delivered call shapes are IDENTICAL to the callback
64
+ # transport (pairs arrays, arity dispatch, UTF-8, PI-data
65
+ # normalization) — the transports are interchangeable to the
66
+ # handler.
67
+ #
68
+ # Transport choice, from the round-XXVI crossover measurements:
69
+ # ONE overridden hot kind pays less through the callbacks (the
70
+ # engine skips C-side emission for unattached kinds entirely:
71
+ # text-only 23ms vs 43ms), while two or more hot kinds win through
72
+ # the recorder (start+chars 138 -> 99ms, start+end+chars 167 ->
73
+ # 122ms vs Nokogiri's 142ms). The weights are document-shape
74
+ # priors — text-heavy documents with tagged structure.
75
+ HOT_KIND_WEIGHTS = {
76
+ characters: 0.6, start_element: 0.2, end_element: 0.2,
77
+ }.freeze
78
+ private_constant :HOT_KIND_WEIGHTS
79
+ DISPATCH_WEIGHT_THRESHOLD = 0.8
80
+ private_constant :DISPATCH_WEIGHT_THRESHOLD
81
+
82
+ def parse_memory_bulk(string)
83
+ recorder = Leptris::XML::SAX::Recorder.open
84
+ begin
85
+ rc = recorder.feed(string, final: true)
86
+ recorder.dispatch(@document, dispatched_kinds)
87
+ if rc != 0
88
+ raise Leptris::XML::ParseError,
89
+ "leptris_sax_parse failed (rc=#{rc})"
90
+ end
91
+ ensure
92
+ recorder.free
93
+ end
94
+ self
95
+ end
96
+
97
+ # The handler's overridden-kind map — the same decisions
98
+ # build_handler_struct makes, in dispatch form. Memoized with the
99
+ # handler struct; document= invalidates both.
100
+ def dispatched_kinds
101
+ @dispatched_kinds ||= begin
102
+ kinds = {}
103
+ {
104
+ start_document: :start_document,
105
+ end_document: :end_document,
106
+ start_element: :start_element,
107
+ end_element: :end_element,
108
+ characters: :characters,
109
+ comment: :comment,
110
+ cdata_block: :cdata,
111
+ processing_instruction: :pi,
112
+ start_prefix_mapping: :start_prefix,
113
+ end_prefix_mapping: :end_prefix,
114
+ error: :error,
115
+ }.each do |method_name, kind|
116
+ next unless overridden?(method_name)
117
+ kinds[kind] =
118
+ if method_name == :start_element &&
119
+ @document.method(:start_element).arity == 1
120
+ :one_arg
121
+ else
122
+ true
123
+ end
124
+ end
125
+ kinds
126
+ end
127
+ end
128
+
129
+ def bulk_dispatch?
130
+ dispatched_kinds.keys.sum do |kind|
131
+ HOT_KIND_WEIGHTS.fetch(kind, 0.0)
132
+ end >= DISPATCH_WEIGHT_THRESHOLD
133
+ end
134
+
53
135
  def parse_io(io)
54
136
  handler_struct = self.handler_struct
55
137
  parser_ptr = Leptris::XML::FFI.leptris_sax_parser_create(
@@ -88,6 +88,161 @@ class Leptris::XML::SAX::Recorder
88
88
  each_event(*Array(kinds), &block)
89
89
  end
90
90
 
91
+ # Which kinds carry each string field (the record layout leaves
92
+ # the other at offset 0 / length 0): slicing a zero-length run
93
+ # allocates a wasted empty String per event — 150k+ of them on a
94
+ # text-heavy document. Unused fields yield nil (the callback
95
+ # transport delivers no name for characters either).
96
+ HAS_NAME = {
97
+ start_element: true, end_element: true,
98
+ pi: true, start_prefix: true, end_prefix: true,
99
+ }.freeze
100
+ HAS_TEXT = {
101
+ characters: true, comment: true, cdata: true,
102
+ pi: true, start_prefix: true, error: true,
103
+ }.freeze
104
+
105
+ # Layout offsets (the struct stays the ABI's single source of
106
+ # truth) and per-count kind-strip templates: one byte per record,
107
+ # skipping the stride — the kind codes are 0..10, so the strip
108
+ # unpacks to cache-resident Fixnums, unlike the old whole-record
109
+ # unpack whose 2.25M-Integer array for a single-feed drain was
110
+ # most of the loop's allocation profile.
111
+ RECORD = Leptris::XML::FFI::SaxEventRecord
112
+ NAME_OFF = RECORD.offset_of(:name_off)
113
+ NAME_LEN = RECORD.offset_of(:name_len)
114
+ TEXT_OFF = RECORD.offset_of(:text_off)
115
+ TEXT_LEN = RECORD.offset_of(:text_len)
116
+ ATTRS_OFF = RECORD.offset_of(:attrs_off)
117
+ ATTR_COUNT = RECORD.offset_of(:attr_count)
118
+ LINE_OFF = RECORD.offset_of(:line)
119
+ COLUMN_OFF = RECORD.offset_of(:column)
120
+ RECORD_STRIDE = RECORD.size
121
+ SAX_EVENT_CHARACTERS_CODE = Leptris::XML::FFI::SAX_EVENT_CHARACTERS
122
+ NUL = "\0".freeze
123
+ KIND_TEMPLATE_FOR = ::Hash.new do |cache, count|
124
+ cache[count] = ("C x#{RECORD_STRIDE - 1}" * count)
125
+ end
126
+
127
+ # Dispatch drain: delivers the recorded events as handler method
128
+ # calls with the CALLBACK transport's exact shapes — pairs arrays
129
+ # (not hashes), one-argument start_element arity dispatch, UTF-8
130
+ # strings, PI-data whitespace normalization. +dispatched+ maps
131
+ # kind symbols to true for the kinds the handler overrides (the
132
+ # attach-only-overridden policy carried onto the bulk transport:
133
+ # other records cost one strip read). The C recorder costs ~12ms
134
+ # per 250k-event document while 250k ffi callback trampolines
135
+ # cost ~150ms — this is how a many-override handler beats the
136
+ # callback transport (and Nokogiri's C extension) outright.
137
+ def dispatch(handler, dispatched)
138
+ count_ptr = ::FFI::MemoryPointer.new(:size_t)
139
+ len_ptr = ::FFI::MemoryPointer.new(:size_t)
140
+ begin
141
+ records_ptr = Leptris::XML::FFI.leptris_sax_recorder_records(
142
+ @handle, count_ptr)
143
+ return self if records_ptr.null? || count_ptr.read_uint64.zero?
144
+ arena_ptr = Leptris::XML::FFI.leptris_sax_recorder_arena(
145
+ @handle, len_ptr)
146
+ arena = arena_ptr.read_bytes(len_ptr.read_uint64)
147
+ count = count_ptr.read_uint64
148
+ records = records_ptr.read_bytes(count * RECORD_STRIDE)
149
+ kinds = records.unpack(KIND_TEMPLATE_FOR[count])
150
+
151
+ one_arg_start = dispatched[:start_element] == :one_arg
152
+ i = 0
153
+ while i < count
154
+ code = kinds[i]
155
+ kind = KIND_BY_CODE[code]
156
+ if kind && dispatched[kind]
157
+ base = i * RECORD_STRIDE
158
+ case kind
159
+ when :characters
160
+ handler.characters(slice(arena,
161
+ records_ptr.get_uint32(base + TEXT_OFF),
162
+ records_ptr.get_uint32(base + TEXT_LEN)))
163
+ when :start_element
164
+ name = slice(arena,
165
+ records_ptr.get_uint32(base + NAME_OFF),
166
+ records_ptr.get_uint32(base + NAME_LEN))
167
+ if one_arg_start
168
+ handler.start_element(name)
169
+ else
170
+ handler.start_element(name, attrs_pairs(arena,
171
+ records_ptr.get_uint32(base + ATTRS_OFF),
172
+ records_ptr.get_uint32(base + ATTR_COUNT)))
173
+ end
174
+ when :end_element
175
+ handler.end_element(slice(arena,
176
+ records_ptr.get_uint32(base + NAME_OFF),
177
+ records_ptr.get_uint32(base + NAME_LEN)))
178
+ when :comment
179
+ handler.comment(slice(arena,
180
+ records_ptr.get_uint32(base + TEXT_OFF),
181
+ records_ptr.get_uint32(base + TEXT_LEN)))
182
+ when :cdata
183
+ handler.cdata_block(slice(arena,
184
+ records_ptr.get_uint32(base + TEXT_OFF),
185
+ records_ptr.get_uint32(base + TEXT_LEN)))
186
+ when :pi
187
+ handler.processing_instruction(slice(arena,
188
+ records_ptr.get_uint32(base + NAME_OFF),
189
+ records_ptr.get_uint32(base + NAME_LEN)),
190
+ Leptris::XML::FFI.read_pi_data(slice(arena,
191
+ records_ptr.get_uint32(base + TEXT_OFF),
192
+ records_ptr.get_uint32(base + TEXT_LEN))))
193
+ when :start_prefix
194
+ handler.start_prefix_mapping(slice(arena,
195
+ records_ptr.get_uint32(base + NAME_OFF),
196
+ records_ptr.get_uint32(base + NAME_LEN)),
197
+ slice(arena,
198
+ records_ptr.get_uint32(base + TEXT_OFF),
199
+ records_ptr.get_uint32(base + TEXT_LEN)))
200
+ when :end_prefix
201
+ handler.end_prefix_mapping(slice(arena,
202
+ records_ptr.get_uint32(base + NAME_OFF),
203
+ records_ptr.get_uint32(base + NAME_LEN)))
204
+ when :error
205
+ handler.error(slice(arena,
206
+ records_ptr.get_uint32(base + TEXT_OFF),
207
+ records_ptr.get_uint32(base + TEXT_LEN)),
208
+ records_ptr.get_uint32(base + LINE_OFF),
209
+ records_ptr.get_uint32(base + COLUMN_OFF))
210
+ when :start_document
211
+ handler.start_document
212
+ when :end_document
213
+ handler.end_document
214
+ end
215
+ end
216
+ i += 1
217
+ end
218
+ ensure
219
+ count_ptr.free
220
+ len_ptr.free
221
+ end
222
+ self
223
+ end
224
+
225
+ # attrs as [name, value] pairs in source order — the callback
226
+ # transport's shape (walk_attr_array).
227
+ def attrs_pairs(arena, off, count)
228
+ return [] if count.zero?
229
+ pairs = []
230
+ pos = off
231
+ nul = NUL
232
+ utf8 = Encoding::UTF_8
233
+ while count > 0
234
+ name_end = arena.index(nul, pos)
235
+ name = arena.byteslice(pos, name_end - pos).force_encoding(utf8)
236
+ pos = name_end + 1
237
+ value_end = arena.index(nul, pos)
238
+ pairs << [name,
239
+ arena.byteslice(pos, value_end - pos).force_encoding(utf8)]
240
+ pos = value_end + 1
241
+ count -= 1
242
+ end
243
+ pairs
244
+ end
245
+
91
246
  # Drains the current chunk's records: bulk-reads the record array
92
247
  # and the packed arena (two FFI calls), then slices every string
93
248
  # from the arena in Ruby — no per-string FFI. With +kinds+, records
@@ -108,27 +263,48 @@ class Leptris::XML::SAX::Recorder
108
263
  arena = arena_ptr.read_bytes(len_ptr.read_uint64)
109
264
  count = count_ptr.read_uint64
110
265
 
111
- # The whole record block in ONE read: read_bytes + String#unpack
112
- # (C-fast) two FFI calls per chunk total (records + arena),
113
- # the true bulk discipline. Template per record: kind byte,
114
- # 7 pad, then the eight uint32 fields (little-endian hosts
115
- # the layout the header freezes for FFI mirrors).
116
- stride = Leptris::XML::FFI::SaxEventRecord.size
117
- fields = records_ptr.read_bytes(count * stride)
118
- .unpack("C x7 V8" * count)
266
+ # Two-level drain: ONE bulk read + kind-strip unpack decides
267
+ # which records to touch, then each yielded event's fields are
268
+ # read individually (get_uint32 at layout offsets) an
269
+ # unwanted record costs one strip read, an unused FIELD costs
270
+ # nothing, and no per-chunk field array is ever built.
271
+ records = records_ptr.read_bytes(count * RECORD_STRIDE)
272
+ kinds = records.unpack(KIND_TEMPLATE_FOR[count])
119
273
 
274
+ has_name = HAS_NAME
275
+ has_text = HAS_TEXT
276
+ drain = wanted.nil?
120
277
  i = 0
121
278
  while i < count
122
- kind = KIND_BY_CODE[fields[i * 9]]
123
- if kind && (wanted.nil? || wanted[kind])
124
- base = i * 9 + 1
125
- text = slice(arena, fields[base + 2], fields[base + 3])
126
- text = Leptris::XML::FFI.read_pi_data(text) if kind == :pi
127
- yield kind,
128
- slice(arena, fields[base], fields[base + 1]),
129
- text,
130
- attrs_from(arena, fields[base + 4], fields[base + 5]),
131
- fields[base + 6], fields[base + 7]
279
+ code = kinds[i]
280
+ base = i * RECORD_STRIDE
281
+ if code == SAX_EVENT_CHARACTERS_CODE && drain
282
+ # The dominant branch (text-heavy documents): no name, no
283
+ # attrs, no per-kind gate lookups — kind, text, position.
284
+ yield :characters, nil,
285
+ slice(arena, records_ptr.get_uint32(base + TEXT_OFF),
286
+ records_ptr.get_uint32(base + TEXT_LEN)),
287
+ nil,
288
+ records_ptr.get_uint32(base + LINE_OFF),
289
+ records_ptr.get_uint32(base + COLUMN_OFF)
290
+ else
291
+ kind = KIND_BY_CODE[code]
292
+ if kind && (drain || wanted[kind])
293
+ name = if has_name[kind]
294
+ slice(arena, records_ptr.get_uint32(base + NAME_OFF),
295
+ records_ptr.get_uint32(base + NAME_LEN))
296
+ end
297
+ text = if has_text[kind]
298
+ slice(arena, records_ptr.get_uint32(base + TEXT_OFF),
299
+ records_ptr.get_uint32(base + TEXT_LEN))
300
+ end
301
+ text = Leptris::XML::FFI.read_pi_data(text) if kind == :pi
302
+ yield kind, name, text,
303
+ attrs_from(arena, records_ptr.get_uint32(base + ATTRS_OFF),
304
+ records_ptr.get_uint32(base + ATTR_COUNT)),
305
+ records_ptr.get_uint32(base + LINE_OFF),
306
+ records_ptr.get_uint32(base + COLUMN_OFF)
307
+ end
132
308
  end
133
309
  i += 1
134
310
  end
@@ -153,9 +329,9 @@ class Leptris::XML::SAX::Recorder
153
329
  kinds.each_with_object({}) { |kind, set| set[kind] = true }
154
330
  end
155
331
 
156
- # Zero-length slices are empty strings, not nil — the default
157
- # namespace prefix is legitimately "" (START_PREFIX: name may be
158
- # ""), matching the callback transport's delivery.
332
+ # The default-namespace prefix is legitimately "" (START_PREFIX:
333
+ # name may be zero-length) — zero-length slices stay "" where the
334
+ # field is carried (unused fields never reach here).
159
335
  def slice(arena, off, len)
160
336
  arena.byteslice(off, len).force_encoding(Encoding::UTF_8)
161
337
  end
@@ -166,15 +342,17 @@ class Leptris::XML::SAX::Recorder
166
342
  return nil if count.zero?
167
343
  hash = {}
168
344
  pos = off
169
- count.times do
170
- name_end = arena.index("\0", pos)
171
- name = arena.byteslice(pos, name_end - pos)
345
+ nul = NUL
346
+ utf8 = Encoding::UTF_8
347
+ while count > 0
348
+ name_end = arena.index(nul, pos)
349
+ name = arena.byteslice(pos, name_end - pos).force_encoding(utf8)
172
350
  pos = name_end + 1
173
- value_end = arena.index("\0", pos)
174
- value = arena.byteslice(pos, value_end - pos)
351
+ value_end = arena.index(nul, pos)
352
+ hash[name] =
353
+ arena.byteslice(pos, value_end - pos).force_encoding(utf8)
175
354
  pos = value_end + 1
176
- hash[name.force_encoding(Encoding::UTF_8)] =
177
- value.force_encoding(Encoding::UTF_8)
355
+ count -= 1
178
356
  end
179
357
  hash
180
358
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: leptris
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.9.33
4
+ version: 1.9.34
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose Inc.