leptris 1.9.24 → 1.9.25

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: e6d5a244d8c1bf6743629a19b9256da3df1159cd6fba153a61f0bdad6037b072
4
- data.tar.gz: 37430812bf170d6e47f8a187cda3669e5b0e422e7420f1da2ec642cff13e1d76
3
+ metadata.gz: afa52b007ec23d222a3562207fcef3f9975f62c11378da91de2d238eff7a74be
4
+ data.tar.gz: 878468ec897ba3f9b60aca28e1379ac712e261c4500c4cc51c2a7379545730ee
5
5
  SHA512:
6
- metadata.gz: 4cf2c7ccd09de5e89588db0bbad0fe9650f892608f6a4a4a08e4c40f5175768091a6f88ff1bedfd8d5b571eb04c9249c09daec7ed3ba99dff0ba1b89d426e0b0
7
- data.tar.gz: '038b8c917cde736d84df62a3bbe9e7c870ccdb210184c0ac49c785d900805f5a5ed71f33e922d56c4abf9a3fa99bf38d0368976b49efa7da2f58d7fadc0dc648'
6
+ metadata.gz: a8caa0b2d6052ff1e72a1d23c59d1f283b887c852e7cb669fa8449d85438f8f89a2005726f96644f6bc46c880d717e54c922210fb9b638c2d890d6d0cdc57fbe
7
+ data.tar.gz: 19fab1e638971c2402b69e70447657c9dfe4d140ba55e588f09b4a394690b34c2e43ba46433615209f66be1660ee6755b83827e7b51e84516c5793b67f44d0d2
data/CHANGELOG.md CHANGED
@@ -5,6 +5,34 @@ 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.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
+
8
36
  ## [1.9.24] - 2026-08-27
9
37
 
10
38
  ### Changed
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Leptris
4
- VERSION = "1.9.24"
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
 
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.24
4
+ version: 1.9.25
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose Inc.