leptris 1.9.23-aarch64-linux → 1.9.25-aarch64-linux

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: 768f0cf3d8fe90544f85e7a776e23889c6a9d6c7f3ddd357ddfe06f76cb4fbd7
4
- data.tar.gz: 65dcfa43a7570ad101012b2adfdddf250c0a7920f3397d477b5dfbdadfde2ef0
3
+ metadata.gz: 8642eda226a6173ab78808b07372a6a04ddb60f1d677a821fdc38b935280100a
4
+ data.tar.gz: 1a4332fd4e95e297d5a863cd12ecbfca410440f1b8fba37d259a50faa2befc38
5
5
  SHA512:
6
- metadata.gz: a6515e7f8fff5e58b87d041b5f55cc24653da447e6a2c58bcf0f112e58a94c685eb7de7239b51339593315ec4d3f32bb114428afa4f8b2e11ab26f490f01886e
7
- data.tar.gz: 95723f1be23ca09341c736b4548849f2806ab08a13ec878acbfc9238cd43d7da192c1ece8b530ac57a1f5721b09d75a7867d9db53531f51fa5b2f9a84e3bf849
6
+ metadata.gz: 58c6d10d826acfc53764f2b2e5f3a3d12f8b73c663024268374663c035430ac206722e1cd28b82c26da72f5a214e5b3626dd8f31463281e0e0d3a5b0294c8329
7
+ data.tar.gz: dcfc09931896ab2a005ff91753e8a60566532cdf043af2316e7f23159d7f531fe6d4bda2c22d2e1ca3907840129cc798a40434c52003e9a905ff2dd985d13f56
data/CHANGELOG.md CHANGED
@@ -5,7 +5,62 @@ 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.22] - 2026-08-27
8
+ ## [1.9.25] - 2026-08-28
9
+
10
+ ### Fixed
11
+
12
+ - **Cold full-tree walk regression (1.9.1 → 1.9.24, ~1.4-1.5x)
13
+ recovered to parity**: the batch-fetch era (1.9.11-1.9.17) bought
14
+ its warm-path wins with per-call machinery the cold branch paid
15
+ on every children() — reproduced, bisected across every published
16
+ release, and profiled end to end this round. Raw cold walk of a
17
+ ~4,200-node catalog (interleaved best-of-5): 1.9.24 5,441 µs ->
18
+ **4,519 µs (-17%)**, vs 1.9.1's 4,369 µs = parity. Through moxml
19
+ the same shape improves 20.1 ms -> 16.5 ms (-18%).
20
+ - `FFI.fetch_children` (and the XPath result batch) now use
21
+ thread-local scratch buffers that grow to the largest family
22
+ seen and are reused — no MemoryPointer allocation/free per
23
+ call, and materially less GC. The count query is paid only when
24
+ the buffer fills exactly (possible truncation): a family that
25
+ fits costs ONE dispatch, not two.
26
+ - `Node.wrap`'s miss path resolves the wrapper cache and pointer
27
+ address once instead of twice — a cold walk wraps every node
28
+ exactly once, so the cache is 100% misses and every redundant
29
+ resolution was pure cost. Identity semantics unchanged.
30
+ - Kept: the per-field version-stamped memo stores on writable
31
+ documents (ADR-0003's writable extension — the residual ~3% vs
32
+ 1.9.1 is that feature working) and the batch architecture
33
+ itself (never more FFI dispatches than the linked-list walk,
34
+ and 2 vs 701 for the 700-child root).
35
+
36
+ ## [1.9.24] - 2026-08-27
37
+
38
+ ### Changed
39
+
40
+ - **Interest-proportional SAX (both transports)**: the handler's
41
+ declared interest — which event methods it overrides — now decides
42
+ what the transport dispatches or drains.
43
+ - `SAX::Parser` attaches a callback only when the handler defines
44
+ the method beyond `SAX::Document`'s no-ops; the C engine already
45
+ skips NULL callbacks, so unwanted events cost nothing on either
46
+ side. Measured per parse of a 1.9 MB / 250k-event document:
47
+ text-only handler 151 ms -> 22 ms (**6.9x**); a handler that
48
+ overrides nothing runs at the C floor, 151 ms -> 7 ms
49
+ (**22x**); elements-only 151 ms -> 121 ms (-20%, the remainder
50
+ is attribute walking). Handlers overriding most kinds stay at
51
+ parity. Duck-typed handlers attach exactly what they define.
52
+ - `SAX::Recorder#each_event(*kinds)` (and `Recorder.parse(...,
53
+ kinds:)`, `#feed_stream(..., kinds:)`) skips records of other
54
+ kinds BEFORE slicing any strings from the arena — an unwanted
55
+ event costs one Array read. Filtered `:characters` drain 201 ms
56
+ -> 119 ms (**-41%**); unknown kinds raise ArgumentError. The
57
+ kind lookup table is now an Array indexed by the event code.
58
+ - Transport choice, by measurement: the pruned callback transport
59
+ beats the filtered recorder for selective consumers (C-side
60
+ emission is skipped entirely), so callbacks remain the default;
61
+ the recorder keeps its niche — bulk raw-event drains per chunk.
62
+
63
+ ## [1.9.23] - 2026-08-27
9
64
 
10
65
  ### Added
11
66
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Leptris
4
- VERSION = "1.9.23"
4
+ VERSION = "1.9.25"
5
5
  end
@@ -817,16 +817,33 @@ module Leptris
817
817
  end
818
818
  end
819
819
 
820
- # All-kind child handle batch (libleptris 1.7.0): count-only
821
- # query sizes the buffer, one fetch copies every child kind,
822
- # one bulk read materializes the pointers.
820
+ # All-kind child handle batch (libleptris 1.7.0). Opportunistic
821
+ # single dispatch: the C call copies up to the capacity handed
822
+ # it and returns what fit, so the count query is paid only when
823
+ # the buffer fills exactly (a possible truncation) — families
824
+ # that fit the scratch buffer cost ONE dispatch, not two.
825
+ FETCH_INITIAL = 32
826
+ private_constant :FETCH_INITIAL
827
+
823
828
  def self.fetch_children(c_ptr)
824
- count = leptris_node_children(c_ptr, nil, 0)
825
- return [] if count.zero?
826
- with_pointer_buffer(count) do |buffer|
827
- copied = leptris_node_children(c_ptr, buffer, count)
828
- buffer.get_array_of_pointer(0, copied)
829
+ scratch = (Thread.current[:leptris_scratch] ||= {})
830
+ buffer = scratch[:pointers]
831
+ if buffer.nil?
832
+ buffer = scratch[:pointers] =
833
+ ::FFI::MemoryPointer.new(:pointer, FETCH_INITIAL)
834
+ end
835
+ cap = buffer.size / PTR_BYTES
836
+ copied = leptris_node_children(c_ptr, buffer, cap)
837
+ if copied == cap
838
+ total = leptris_node_children(c_ptr, nil, 0)
839
+ if total > cap
840
+ buffer.free
841
+ buffer = scratch[:pointers] =
842
+ ::FFI::MemoryPointer.new(:pointer, total)
843
+ copied = leptris_node_children(c_ptr, buffer, total)
844
+ end
829
845
  end
846
+ buffer.get_array_of_pointer(0, copied)
830
847
  end
831
848
 
832
849
  # XPath result-set batch (leptris_xpath_result_get_nodes_ex):
@@ -840,18 +857,13 @@ module Leptris
840
857
 
841
858
  def self.fetch_result_nodes(result_ptr, count)
842
859
  return [[], nil] if count.zero?
843
- with_pointer_buffer(count) do |buffer|
844
- kinds = ::FFI::MemoryPointer.new(:int, count)
845
- begin
846
- copied = leptris_xpath_result_get_nodes_ex(
847
- result_ptr, buffer, kinds, count)
848
- hints = kinds.get_array_of_int(0, copied)
849
- .map { |k| XPATH_KIND_HINT[k] }
850
- [buffer.get_array_of_pointer(0, copied), hints]
851
- ensure
852
- kinds.free
853
- end
854
- end
860
+ buffer = scratch_pointers(count)
861
+ kinds = scratch_ints(count)
862
+ copied = leptris_xpath_result_get_nodes_ex(
863
+ result_ptr, buffer, kinds, count)
864
+ hints = kinds.get_array_of_int(0, copied)
865
+ .map { |k| XPATH_KIND_HINT[k] }
866
+ [buffer.get_array_of_pointer(0, copied), hints]
855
867
  end
856
868
 
857
869
  # Fragment parse with the status out-param read: returns
@@ -863,16 +875,38 @@ module Leptris
863
875
  [raw, status.read_int]
864
876
  end
865
877
 
866
- # The one buffer-allocation point for handle-array fetches:
867
- # everything MemoryPointer-related above this line is either
868
- # an attached C call or another seam helper.
869
- def self.with_pointer_buffer(count)
870
- buffer = ::FFI::MemoryPointer.new(:pointer, count)
871
- begin
872
- yield buffer
873
- ensure
874
- buffer.free
878
+ # Thread-local scratch buffers for batch handle fetches (round
879
+ # XX): the per-call MemoryPointer new/free was pure cold-path
880
+ # overhead — two allocations and a GC visit per children() /
881
+ # result-set batch, ~15% of a cold full-tree walk between
882
+ # 1.9.1 and 1.9.24. The buffers grow to the largest request
883
+ # seen (bounded by the widest element or result set) and are
884
+ # reused. Safe: no Ruby reentrancy during the C call, and
885
+ # per-thread storage keeps concurrent fetches independent.
886
+ # Never freed — a few KB pinned per thread.
887
+ PTR_BYTES = ::FFI.type_size(:pointer)
888
+ private_constant :PTR_BYTES
889
+ INT_BYTES = ::FFI.type_size(:int)
890
+ private_constant :INT_BYTES
891
+
892
+ def self.scratch_pointers(count)
893
+ scratch = (Thread.current[:leptris_scratch] ||= {})
894
+ ptr = scratch[:pointers]
895
+ if ptr.nil? || ptr.size / PTR_BYTES < count
896
+ ptr&.free
897
+ ptr = scratch[:pointers] = ::FFI::MemoryPointer.new(:pointer, count)
898
+ end
899
+ ptr
900
+ end
901
+
902
+ def self.scratch_ints(count)
903
+ scratch = (Thread.current[:leptris_scratch] ||= {})
904
+ ptr = scratch[:ints]
905
+ if ptr.nil? || ptr.size / INT_BYTES < count
906
+ ptr&.free
907
+ ptr = scratch[:ints] = ::FFI::MemoryPointer.new(:int, count)
875
908
  end
909
+ ptr
876
910
  end
877
911
  end
878
912
  end
@@ -19,9 +19,15 @@ class Leptris::XML::Node
19
19
  # Per-document weak-ref cache. Returns the existing wrapper when the
20
20
  # same c_ptr is wrapped twice (common in children/sibling walks,
21
21
  # repeated xpath queries, traverse-then-access patterns). The cache
22
- # dies with the document so no stale entries.
23
- if document && (cached = document.wrapper_cache[c_ptr.address])
24
- return cached
22
+ # dies with the document so no stale entries. The miss path
23
+ # resolves the cache and address once — a cold walk wraps every
24
+ # node exactly once and pays both only on the store.
25
+ if document
26
+ cache = document.wrapper_cache
27
+ address = c_ptr.address
28
+ if (cached = cache[address])
29
+ return cached
30
+ end
25
31
  end
26
32
 
27
33
  node_type ||= Leptris::XML::FFI.leptris_node_get_type(c_ptr)
@@ -41,7 +47,7 @@ class Leptris::XML::Node
41
47
  new(c_ptr, document, parent: parent, node_type: node_type)
42
48
  end
43
49
 
44
- document.wrapper_cache[c_ptr.address] = node if document
50
+ cache[address] = node if document
45
51
  node
46
52
  end
47
53
 
@@ -83,59 +83,95 @@ class Leptris::XML::SAX::Parser
83
83
  private
84
84
 
85
85
  # Build a LeptrisSAXHandler struct populated with FFI::Function callbacks
86
- # that dispatch to the Ruby handler.
86
+ # that dispatch to the Ruby handler. Only the callbacks the handler
87
+ # actually overrides are attached: the C engine skips NULL callbacks,
88
+ # so cost scales with the handler's declared interest, not with the
89
+ # document's event mix (measured 4.9x for an elements-only handler on
90
+ # a 1.9 MB stream).
87
91
  def build_handler_struct
88
92
  s = Leptris::XML::FFI::SAXHandler.new
89
93
  handler = @document # capture in closures
90
94
 
91
- s[:start_document] = callback(:void, [:pointer]) do |_|
92
- handler.start_document
95
+ if overridden?(:start_document)
96
+ s[:start_document] = callback(:void, [:pointer]) do |_|
97
+ handler.start_document
98
+ end
93
99
  end
94
100
 
95
- s[:end_document] = callback(:void, [:pointer]) do |_|
96
- handler.end_document
101
+ if overridden?(:end_document)
102
+ s[:end_document] = callback(:void, [:pointer]) do |_|
103
+ handler.end_document
104
+ end
97
105
  end
98
106
 
99
- s[:start_element] = callback(:void, [:pointer, :string, :pointer]) do |_, name, attrs_ptr|
100
- attrs = walk_attr_array(attrs_ptr)
101
- handler.start_element(utf8(name), attrs)
107
+ if overridden?(:start_element)
108
+ s[:start_element] = callback(:void, [:pointer, :string, :pointer]) do |_, name, attrs_ptr|
109
+ attrs = walk_attr_array(attrs_ptr)
110
+ handler.start_element(utf8(name), attrs)
111
+ end
102
112
  end
103
113
 
104
- s[:end_element] = callback(:void, [:pointer, :string]) do |_, name|
105
- handler.end_element(utf8(name))
114
+ if overridden?(:end_element)
115
+ s[:end_element] = callback(:void, [:pointer, :string]) do |_, name|
116
+ handler.end_element(utf8(name))
117
+ end
106
118
  end
107
119
 
108
- s[:characters] = callback(:void, [:pointer, :pointer, :size_t]) do |_, text_ptr, len|
109
- handler.characters(text_ptr.read_bytes(len).force_encoding(Encoding::UTF_8))
120
+ if overridden?(:characters)
121
+ s[:characters] = callback(:void, [:pointer, :pointer, :size_t]) do |_, text_ptr, len|
122
+ handler.characters(text_ptr.read_bytes(len).force_encoding(Encoding::UTF_8))
123
+ end
110
124
  end
111
125
 
112
- s[:comment] = callback(:void, [:pointer, :string]) do |_, comment|
113
- handler.comment(utf8(comment))
126
+ if overridden?(:comment)
127
+ s[:comment] = callback(:void, [:pointer, :string]) do |_, comment|
128
+ handler.comment(utf8(comment))
129
+ end
114
130
  end
115
131
 
116
- s[:cdata] = callback(:void, [:pointer, :string]) do |_, cdata|
117
- handler.cdata_block(utf8(cdata))
132
+ if overridden?(:cdata_block)
133
+ s[:cdata] = callback(:void, [:pointer, :string]) do |_, cdata|
134
+ handler.cdata_block(utf8(cdata))
135
+ end
118
136
  end
119
137
 
120
- s[:processing_instruction] = callback(:void, [:pointer, :string, :string]) do |_, target, data|
121
- handler.processing_instruction(utf8(target), utf8(data))
138
+ if overridden?(:processing_instruction)
139
+ s[:processing_instruction] = callback(:void, [:pointer, :string, :string]) do |_, target, data|
140
+ handler.processing_instruction(utf8(target), utf8(data))
141
+ end
122
142
  end
123
143
 
124
- s[:start_prefix_mapping] = callback(:void, [:pointer, :string, :string]) do |_, prefix, uri|
125
- handler.start_prefix_mapping(utf8(prefix), utf8(uri))
144
+ if overridden?(:start_prefix_mapping)
145
+ s[:start_prefix_mapping] = callback(:void, [:pointer, :string, :string]) do |_, prefix, uri|
146
+ handler.start_prefix_mapping(utf8(prefix), utf8(uri))
147
+ end
126
148
  end
127
149
 
128
- s[:end_prefix_mapping] = callback(:void, [:pointer, :string]) do |_, prefix|
129
- handler.end_prefix_mapping(utf8(prefix))
150
+ if overridden?(:end_prefix_mapping)
151
+ s[:end_prefix_mapping] = callback(:void, [:pointer, :string]) do |_, prefix|
152
+ handler.end_prefix_mapping(utf8(prefix))
153
+ end
130
154
  end
131
155
 
132
- s[:error] = callback(:void, [:pointer, :string, :int, :int]) do |_, msg, line, col|
133
- handler.error(utf8(msg), line, col)
156
+ if overridden?(:error)
157
+ s[:error] = callback(:void, [:pointer, :string, :int, :int]) do |_, msg, line, col|
158
+ handler.error(utf8(msg), line, col)
159
+ end
134
160
  end
135
161
 
136
162
  s
137
163
  end
138
164
 
165
+ # True when the handler defines the event method beyond the no-op
166
+ # every SAX::Document carries — subclasses, included modules, and
167
+ # duck-typed handlers all qualify; a handler that overrides nothing
168
+ # attaches nothing and the parse runs at the C floor.
169
+ def overridden?(name)
170
+ @document.method(name).owner != Leptris::XML::SAX::Document
171
+ rescue NameError
172
+ false
173
+ end
174
+
139
175
  def callback(return_type, params, blocking: true, &block)
140
176
  ::FFI::Function.new(return_type, params, blocking: blocking, &block)
141
177
  end
@@ -29,6 +29,10 @@ class Leptris::XML::SAX::Recorder
29
29
  Leptris::XML::FFI::SAX_EVENT_ERROR => :error,
30
30
  }.freeze
31
31
 
32
+ # Kind symbol per code byte — Array indexing in the drain loop,
33
+ # not a Hash lookup per event.
34
+ KIND_BY_CODE = Array.new(KINDS.size) { |code| KINDS[code] }.freeze
35
+
32
36
  def self.open
33
37
  raw = Leptris::XML::FFI.leptris_sax_recorder_new
34
38
  if raw.null?
@@ -38,14 +42,17 @@ class Leptris::XML::SAX::Recorder
38
42
  end
39
43
 
40
44
  # One-shot parse over a complete document, yielding Record structs.
41
- def self.parse(xml_or_io)
45
+ # +kinds+ filters the drain (see #each_event) — pass e.g.
46
+ # `kinds: [:start_element]` to slice strings only for the events
47
+ # you consume.
48
+ def self.parse(xml_or_io, kinds: nil)
42
49
  recorder = open
43
50
  begin
44
51
  if xml_or_io.respond_to?(:read)
45
- recorder.feed_stream(xml_or_io) { |*args| yield(*args) }
52
+ recorder.feed_stream(xml_or_io, kinds: kinds) { |*args| yield(*args) }
46
53
  else
47
54
  recorder.feed(xml_or_io.to_s, final: true)
48
- recorder.each_event { |*args| yield(*args) }
55
+ recorder.each_event(*Array(kinds)) { |*args| yield(*args) }
49
56
  end
50
57
  ensure
51
58
  recorder.free
@@ -72,19 +79,23 @@ class Leptris::XML::SAX::Recorder
72
79
  end
73
80
 
74
81
  # Feed an IO in chunks, draining events after each chunk.
75
- def feed_stream(io, chunk_size = 65_536, &block)
82
+ def feed_stream(io, chunk_size = 65_536, kinds: nil, &block)
76
83
  while (chunk = io.read(chunk_size))
77
84
  feed(chunk)
78
- each_event(&block)
85
+ each_event(*Array(kinds), &block)
79
86
  end
80
87
  feed("", final: true)
81
- each_event(&block)
88
+ each_event(*Array(kinds), &block)
82
89
  end
83
90
 
84
91
  # Drains the current chunk's records: bulk-reads the record array
85
92
  # and the packed arena (two FFI calls), then slices every string
86
- # from the arena in Ruby — no per-string FFI.
87
- def each_event
93
+ # from the arena in Ruby — no per-string FFI. With +kinds+, records
94
+ # of other kinds are skipped BEFORE any string is sliced: an
95
+ # unwanted event costs one Array read, and cost scales with what
96
+ # the consumer asked for, not with the document's event mix.
97
+ def each_event(*kinds)
98
+ wanted = kinds.empty? ? nil : wanted_set(kinds)
88
99
  count_ptr = ::FFI::MemoryPointer.new(:size_t)
89
100
  len_ptr = ::FFI::MemoryPointer.new(:size_t)
90
101
  begin
@@ -108,8 +119,8 @@ class Leptris::XML::SAX::Recorder
108
119
 
109
120
  i = 0
110
121
  while i < count
111
- kind = KINDS[fields[i * 9]]
112
- if kind
122
+ kind = KIND_BY_CODE[fields[i * 9]]
123
+ if kind && (wanted.nil? || wanted[kind])
113
124
  base = i * 9 + 1
114
125
  yield kind,
115
126
  slice(arena, fields[base], fields[base + 1]),
@@ -128,6 +139,18 @@ class Leptris::XML::SAX::Recorder
128
139
 
129
140
  private
130
141
 
142
+ def wanted_set(kinds)
143
+ unknown = kinds.each_with_object([]) do |kind, acc|
144
+ acc << kind unless KINDS.value?(kind)
145
+ end
146
+ unless unknown.empty?
147
+ raise ArgumentError,
148
+ "unknown event kinds #{unknown.inspect} " \
149
+ "(known: #{KINDS.values.inspect})"
150
+ end
151
+ kinds.each_with_object({}) { |kind, set| set[kind] = true }
152
+ end
153
+
131
154
  # Zero-length slices are empty strings, not nil — the default
132
155
  # namespace prefix is legitimately "" (START_PREFIX: name may be
133
156
  # ""), matching the callback transport's delivery.
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.23
4
+ version: 1.9.25
5
5
  platform: aarch64-linux
6
6
  authors:
7
7
  - Ribose Inc.