taurus 0.1.0
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 +7 -0
- data/.rspec +3 -0
- data/.rubocop.yml +8 -0
- data/CHANGELOG.md +518 -0
- data/CLAUDE.md +104 -0
- data/LICENSE.md +33 -0
- data/README.adoc +1529 -0
- data/Rakefile +7 -0
- data/TODO.impl/01-architecture.md +217 -0
- data/TODO.impl/02-ffi-declarations.md +236 -0
- data/TODO.impl/03-document-node-element-nodeset.md +382 -0
- data/TODO.impl/04-sax-parser.md +203 -0
- data/TODO.impl/05-serialize-c14n-memory-specs-css.md +276 -0
- data/benchmark/README.md +168 -0
- data/benchmark/taurus_vs_nokogiri.rb +105 -0
- data/docs/ARCHITECTURE.adoc +559 -0
- data/docs/BUILD.md +395 -0
- data/docs/ERROR_MESSAGES.md +458 -0
- data/docs/FFI_ARCHITECTURE.md +439 -0
- data/docs/FUTURE_VISION.md +303 -0
- data/docs/GITHUB_ACTIONS.md +293 -0
- data/docs/OPTIMIZATIONS_IMPLEMENTED.adoc +459 -0
- data/docs/PERFORMANCE.adoc +668 -0
- data/docs/PERFORMANCE.md +448 -0
- data/docs/RELEASE_NOTES_v1.0.0.md +515 -0
- data/docs/XPATH_SPEC_COMPLIANCE.md +298 -0
- data/docs/completion/taurus.bash +86 -0
- data/docs/completion/taurus.zsh +74 -0
- data/docs/man/taurus-format.1 +227 -0
- data/docs/man/taurus-parse.1 +178 -0
- data/docs/man/taurus-xpath.1 +312 -0
- data/docs/man/taurus.1 +160 -0
- data/docs/v0.9.0_PERFORMANCE_IMPROVEMENTS.md +217 -0
- data/docs/v0.9.0_RELEASE_SUMMARY.md +281 -0
- data/docs/v1.0.0_CONTINUATION_PLAN.md +172 -0
- data/docs/v1.0.0_CONTINUATION_PROMPT.md +382 -0
- data/docs/v1.0.0_SESSION_6_CONTINUATION.md +434 -0
- data/docs/v1.0.0_SESSION_6_PROMPT.md +231 -0
- data/docs/v1.0.0_STATUS_TRACKER.md +224 -0
- data/docs/v1.1.0_CONTINUATION_PLAN.md +299 -0
- data/docs/v1.1.0_FINAL_CONTINUATION_PLAN.md +201 -0
- data/docs/v1.1.0_SESSION_3_PROMPT.md +223 -0
- data/docs/v1.1.0_STATUS_TRACKER.md +355 -0
- data/docs/xml-performance.adoc +115 -0
- data/docs/xpath-performance.adoc +379 -0
- data/lib/taurus/version.rb +5 -0
- data/lib/taurus/xml/attr.rb +43 -0
- data/lib/taurus/xml/c14n.rb +23 -0
- data/lib/taurus/xml/cdata.rb +16 -0
- data/lib/taurus/xml/comment.rb +16 -0
- data/lib/taurus/xml/css_to_xpath.rb +177 -0
- data/lib/taurus/xml/doc_type.rb +54 -0
- data/lib/taurus/xml/document.rb +202 -0
- data/lib/taurus/xml/document_fragment.rb +42 -0
- data/lib/taurus/xml/element.rb +278 -0
- data/lib/taurus/xml/ffi.rb +420 -0
- data/lib/taurus/xml/namespace.rb +43 -0
- data/lib/taurus/xml/node.rb +221 -0
- data/lib/taurus/xml/node_set.rb +143 -0
- data/lib/taurus/xml/parse_options.rb +19 -0
- data/lib/taurus/xml/processing_instruction.rb +26 -0
- data/lib/taurus/xml/sax/document.rb +45 -0
- data/lib/taurus/xml/sax/parser.rb +148 -0
- data/lib/taurus/xml/sax.rb +12 -0
- data/lib/taurus/xml/searchable.rb +93 -0
- data/lib/taurus/xml/text.rb +16 -0
- data/lib/taurus/xml.rb +29 -0
- data/lib/taurus.rb +7 -0
- data/taurus.gemspec +42 -0
- metadata +157 -0
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
# TODO 5 — Serialization, C14N, memory management, specs, CSS
|
|
2
|
+
|
|
3
|
+
## Serialization
|
|
4
|
+
|
|
5
|
+
Wrap `taurus_serialize_document` and `taurus_c14n_canonicalize`.
|
|
6
|
+
|
|
7
|
+
```ruby
|
|
8
|
+
# lib/taurus/xml/serialize_options.rb
|
|
9
|
+
class Taurus::XML::SerializeOptions < FFI::Struct
|
|
10
|
+
layout \
|
|
11
|
+
:indent, :int,
|
|
12
|
+
:xml_declaration, :int,
|
|
13
|
+
:no_empty_tags, :int,
|
|
14
|
+
:preserve_whitespace, :int
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Document#to_xml
|
|
18
|
+
def to_xml(options = {})
|
|
19
|
+
opts = SerializeOptions.new
|
|
20
|
+
opts[:indent] = options[:indent] || 0
|
|
21
|
+
opts[:xml_declaration] = options[:no_decl] ? 0 : 1
|
|
22
|
+
opts[:no_empty_tags] = options[:no_empty_tags] ? 1 : 0
|
|
23
|
+
opts[:preserve_whitespace] = options[:preserve_whitespace] ? 1 : 0
|
|
24
|
+
ptr = FFI.taurus_serialize_document(@c_ptr, opts.pointer)
|
|
25
|
+
return '' if ptr.nil? || ptr.null?
|
|
26
|
+
str = ptr.read_string
|
|
27
|
+
FFI.taurus_free_string(ptr)
|
|
28
|
+
str
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Node#to_xml (serialize just this subtree)
|
|
32
|
+
def to_xml(options = {})
|
|
33
|
+
# No C API for single-node serialization yet. Use Document serialize
|
|
34
|
+
# with a filter, or build the string manually. For v0.4.2, use the
|
|
35
|
+
# document-level serialize and post-process. This is a known limitation.
|
|
36
|
+
document.to_xml(options)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Node#inner_html
|
|
40
|
+
def inner_html
|
|
41
|
+
children.map { |c| c.to_xml }.join
|
|
42
|
+
end
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## C14N (Canonical XML)
|
|
46
|
+
|
|
47
|
+
```ruby
|
|
48
|
+
# lib/taurus/xml/c14n.rb
|
|
49
|
+
module Taurus::XML
|
|
50
|
+
C14N_1_0 = 0
|
|
51
|
+
C14N_1_1 = 1
|
|
52
|
+
C14N_EXCLUSIVE = 2
|
|
53
|
+
|
|
54
|
+
class Document
|
|
55
|
+
def canonicalize(mode = C14N_1_0, with_comments = false)
|
|
56
|
+
ptr = FFI.taurus_c14n_canonicalize(@c_ptr, mode, with_comments ? 1 : 0)
|
|
57
|
+
return '' if ptr.nil? || ptr.null?
|
|
58
|
+
str = ptr.read_string
|
|
59
|
+
FFI.taurus_free_string(ptr)
|
|
60
|
+
str
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## Memory management
|
|
67
|
+
|
|
68
|
+
### Ownership rules
|
|
69
|
+
|
|
70
|
+
| Ruby class | Owns C memory? | Free function |
|
|
71
|
+
|-------------|----------------|---------------|
|
|
72
|
+
| Document | YES | `taurus_document_free` |
|
|
73
|
+
| Node/Element| NO (borrowed) | none (freed by Document) |
|
|
74
|
+
| NodeSet | YES (XPath result) | `taurus_xpath_result_free` |
|
|
75
|
+
| Attr | NO (borrowed) | none |
|
|
76
|
+
|
|
77
|
+
### Explicit free pattern
|
|
78
|
+
|
|
79
|
+
```ruby
|
|
80
|
+
doc = Taurus::XML.parse(xml)
|
|
81
|
+
begin
|
|
82
|
+
# ... work with doc ...
|
|
83
|
+
ensure
|
|
84
|
+
doc.free
|
|
85
|
+
end
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
### GC safety net
|
|
89
|
+
|
|
90
|
+
```ruby
|
|
91
|
+
class Taurus::XML::Document
|
|
92
|
+
def self.wrap(ptr)
|
|
93
|
+
obj = allocate
|
|
94
|
+
obj.instance_variable_set(:@c_ptr, ptr)
|
|
95
|
+
ObjectSpace.define_finalizer(obj, finalizer(ptr))
|
|
96
|
+
obj
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def self.finalizer(ptr)
|
|
100
|
+
proc { FFI.taurus_document_free(ptr) if ptr && !ptr.null? }
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def free
|
|
104
|
+
return unless @c_ptr
|
|
105
|
+
FFI.taurus_document_free(@c_ptr)
|
|
106
|
+
@c_ptr = nil
|
|
107
|
+
# The finalizer still holds the old pointer but Document#free
|
|
108
|
+
# already freed it. Add a "freed" flag to detect double-free.
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
**IMPORTANT**: The finalizer must capture the POINTER VALUE, not the
|
|
114
|
+
Document object (which would prevent GC). Use `FFI::Pointer` directly
|
|
115
|
+
in the finalizer closure.
|
|
116
|
+
|
|
117
|
+
### Prevent use-after-free
|
|
118
|
+
|
|
119
|
+
```ruby
|
|
120
|
+
class Taurus::XML::Node
|
|
121
|
+
def c_ptr
|
|
122
|
+
raise UseAfterFreeError, "document has been freed" unless @document.c_ptr
|
|
123
|
+
@c_ptr
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
## Specs
|
|
129
|
+
|
|
130
|
+
### Structure
|
|
131
|
+
|
|
132
|
+
```
|
|
133
|
+
spec/
|
|
134
|
+
spec_helper.rb
|
|
135
|
+
xml/
|
|
136
|
+
parse_spec.rb
|
|
137
|
+
document_spec.rb
|
|
138
|
+
node_spec.rb
|
|
139
|
+
element_spec.rb
|
|
140
|
+
node_set_spec.rb
|
|
141
|
+
xpath_spec.rb
|
|
142
|
+
sax_spec.rb
|
|
143
|
+
serialize_spec.rb
|
|
144
|
+
c14n_spec.rb
|
|
145
|
+
memory_spec.rb
|
|
146
|
+
fixtures/
|
|
147
|
+
basic.xml
|
|
148
|
+
catalog.xml
|
|
149
|
+
namespaces.xml
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
### Test against Nokogiri behavior
|
|
153
|
+
|
|
154
|
+
Where Nokogiri's behavior is well-defined, match it exactly. The
|
|
155
|
+
specs should test:
|
|
156
|
+
|
|
157
|
+
```ruby
|
|
158
|
+
# Parse
|
|
159
|
+
doc = Taurus::XML.parse('<root><child id="1">text</child></root>')
|
|
160
|
+
expect(doc.root.name).to eq('root')
|
|
161
|
+
expect(doc.root.children.first['id']).to eq('1')
|
|
162
|
+
|
|
163
|
+
# XPath
|
|
164
|
+
doc = Taurus::XML.parse('<lib><book/><book/></lib>')
|
|
165
|
+
expect(doc.xpath('count(//book)')).to eq(2.0)
|
|
166
|
+
expect(doc.xpath('//book').length).to eq(2)
|
|
167
|
+
expect(doc.at_xpath('//book')).to be_a(Taurus::XML::Element)
|
|
168
|
+
|
|
169
|
+
# Search
|
|
170
|
+
doc = Taurus::XML.parse('<root><a class="x"/><a class="y"/></root>')
|
|
171
|
+
expect(doc.search('a').length).to eq(2)
|
|
172
|
+
expect(doc.at('a')['class']).to eq('x')
|
|
173
|
+
|
|
174
|
+
# SAX
|
|
175
|
+
class Handler < Taurus::XML::SAX::Document
|
|
176
|
+
attr_reader :elements
|
|
177
|
+
def initialize; @elements = []; end
|
|
178
|
+
def start_element(name, attrs = []); @elements << name; end
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
h = Handler.new
|
|
182
|
+
Taurus::XML::SAX::Parser.new(h).parse('<r><a/><b/></r>')
|
|
183
|
+
expect(h.elements).to eq(['r', 'a', 'b'])
|
|
184
|
+
|
|
185
|
+
# Serialize
|
|
186
|
+
doc = Taurus::XML.parse('<r/>')
|
|
187
|
+
expect(doc.to_xml).to match(/<r\/>/)
|
|
188
|
+
|
|
189
|
+
# C14N
|
|
190
|
+
expect(doc.canonicalize).to include('<r></r>')
|
|
191
|
+
|
|
192
|
+
# Memory
|
|
193
|
+
doc = Taurus::XML.parse('<r/>')
|
|
194
|
+
doc.free
|
|
195
|
+
expect { doc.root }.to raise_error(Taurus::XML::UseAfterFreeError)
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
### Conformance
|
|
199
|
+
|
|
200
|
+
Run Nokogiri's own test suite against the Taurus binding where
|
|
201
|
+
possible. Skip tests for features Taurus doesn't support (HTML5,
|
|
202
|
+
XSLT, RelaxNG, DTD validation beyond what libtaurus provides).
|
|
203
|
+
|
|
204
|
+
## CSS-to-XPath converter (minimal)
|
|
205
|
+
|
|
206
|
+
```ruby
|
|
207
|
+
# lib/taurus/xml/css_to_xpath.rb
|
|
208
|
+
module Taurus::XML
|
|
209
|
+
module CssToXPath
|
|
210
|
+
def self.convert(rule)
|
|
211
|
+
parts = rule.strip.split(/\s+/)
|
|
212
|
+
xpath_parts = parts.map { |p| convert_part(p) }
|
|
213
|
+
'//' + xpath_parts.join('/')
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def self.convert_part(part)
|
|
217
|
+
# tag → tag
|
|
218
|
+
# .class → *[contains(concat(' ', @class,' '),' class ')]
|
|
219
|
+
# #id → *[@id='id']
|
|
220
|
+
# [attr] → *[@attr]
|
|
221
|
+
# [attr=val] → *[@attr='val']
|
|
222
|
+
# > child handled by split
|
|
223
|
+
# :first-child → *[position()=1]
|
|
224
|
+
# :last-child → *[position()=last()]
|
|
225
|
+
return '*' if part == '*'
|
|
226
|
+
|
|
227
|
+
if part.start_with?('.')
|
|
228
|
+
cls = part[1..]
|
|
229
|
+
"*[contains(concat(' ',normalize-space(@class),' '),' #{cls} ')]"
|
|
230
|
+
elsif part.start_with?('#')
|
|
231
|
+
id = part[1..]
|
|
232
|
+
"*[@id='#{id}']"
|
|
233
|
+
elsif match = part.match(/^(\w+)\[(\w+)='?([^'\]]+)'?\]$/i)
|
|
234
|
+
"#{match[1]}[@#{match[2]}='#{match[3]}']"
|
|
235
|
+
elsif match = part.match(/^(\w+)\[(\w+)\]$/i)
|
|
236
|
+
"#{match[1]}[@#{match[2]}]"
|
|
237
|
+
elsif match = part.match(/^(\w+):first-child$/i)
|
|
238
|
+
"#{match[1]}[position()=1]"
|
|
239
|
+
elsif match = part.match(/^(\w+):last-child$/i)
|
|
240
|
+
"#{match[1]}[position()=last()]"
|
|
241
|
+
else
|
|
242
|
+
part # pass through as tag name
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
This is a minimal converter. For full CSS3 support, integrate the
|
|
250
|
+
`css_parser` gem or port Nokogiri's CSS parser.
|
|
251
|
+
|
|
252
|
+
## File layout summary
|
|
253
|
+
|
|
254
|
+
```
|
|
255
|
+
lib/taurus.rb
|
|
256
|
+
lib/taurus/xml.rb
|
|
257
|
+
lib/taurus/xml/
|
|
258
|
+
ffi.rb
|
|
259
|
+
document.rb
|
|
260
|
+
node.rb
|
|
261
|
+
element.rb
|
|
262
|
+
text.rb
|
|
263
|
+
comment.rb
|
|
264
|
+
cdata.rb
|
|
265
|
+
processing_instruction.rb
|
|
266
|
+
attr.rb
|
|
267
|
+
node_set.rb
|
|
268
|
+
searchable.rb
|
|
269
|
+
parse_options.rb
|
|
270
|
+
serialize_options.rb
|
|
271
|
+
css_to_xpath.rb
|
|
272
|
+
sax.rb
|
|
273
|
+
sax/
|
|
274
|
+
parser.rb
|
|
275
|
+
document.rb
|
|
276
|
+
```
|
data/benchmark/README.md
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
# taurus-ruby vs Nokogiri — Ruby-level benchmarks
|
|
2
|
+
|
|
3
|
+
Compares `Taurus::XML` (FFI → libtaurus v0.12.0) against `Nokogiri::XML`
|
|
4
|
+
(C extension → libxml2) on the operations that matter for typical use.
|
|
5
|
+
|
|
6
|
+
Run with:
|
|
7
|
+
|
|
8
|
+
```
|
|
9
|
+
bundle exec ruby -Ilib benchmark/taurus_vs_nokogiri.rb
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
## Latest run (M1, libtaurus v0.12.0, Nokogiri 1.19.4)
|
|
13
|
+
|
|
14
|
+
All libtaurus upstream issues closed (#166–#262, 21 issues total).
|
|
15
|
+
v0.12.0 ships the [#262](https://github.com/lutaml/taurus/issues/262) proposals:
|
|
16
|
+
batch XPath result accessor (`taurus_xpath_result_get_nodes`) and per-node
|
|
17
|
+
`binding_wrapper` field. The Ruby binding uses the batch accessor in
|
|
18
|
+
`NodeSet#each`; the `binding_wrapper` is left for the libtaurus team's
|
|
19
|
+
other bindings (Python ctypes, etc.) — the Ruby binding's per-Document
|
|
20
|
+
`ObjectSpace::WeakMap` cache is faster (no FFI call per cache lookup).
|
|
21
|
+
|
|
22
|
+
Median of 3 runs (run-to-run variance is significant on cold starts and
|
|
23
|
+
shared-runner workloads; the trend is stable):
|
|
24
|
+
|
|
25
|
+
| Operation | Taurus | Nokogiri | Taurus / Nokogiri | Winner |
|
|
26
|
+
|---|---:|---:|---:|---|
|
|
27
|
+
| Parse small (431 B) | ~6 µs | ~14 µs | **~2×** | Taurus (variable) |
|
|
28
|
+
| Parse medium (12 KB) | ~32 µs | ~200 µs | **~6×** | Taurus |
|
|
29
|
+
| XPath `count(//book)` | ~1.5 µs | ~9 µs | **~6×** | Taurus |
|
|
30
|
+
| XPath `//book` (100-node nodeset) | ~4 µs | ~13 µs | **~5×** | Taurus |
|
|
31
|
+
| XPath `//book[@id='50']` (1 match) | ~6 µs | ~80 µs | **~10×** | Taurus |
|
|
32
|
+
| XPath `//book[price > 50]` | ~80 µs | ~100 µs | **~1.3×** | Taurus |
|
|
33
|
+
| XPath `//author \| //title` (union) | ~14 µs | ~25 µs | **~3×** | Taurus |
|
|
34
|
+
| Tree traversal | ~900 µs | ~500 µs | 0.55× | Nokogiri (1.8× faster) |
|
|
35
|
+
| Serialize | ~28 µs | ~85 µs | **~3×** | Taurus |
|
|
36
|
+
|
|
37
|
+
**Taurus beats Nokogiri on 8 of 9 operations.** Only tree traversal still
|
|
38
|
+
loses, by ~1.8×.
|
|
39
|
+
|
|
40
|
+
### Why tree traversal still loses
|
|
41
|
+
|
|
42
|
+
`Node#traverse` visits every node and materializes each one via
|
|
43
|
+
`Node.wrap`. The per-Document `ObjectSpace::WeakMap` cache helps on
|
|
44
|
+
repeated traversals of the same doc but not on a single one. Each
|
|
45
|
+
visited node pays:
|
|
46
|
+
- 1 FFI call to `taurus_node_first_child` / `_next_sibling`
|
|
47
|
+
- 1 FFI call to `taurus_node_get_type` (for wrap dispatch)
|
|
48
|
+
- 1 Ruby object allocation (cache miss)
|
|
49
|
+
|
|
50
|
+
Nokogiri's libxml2 C extension handles traversal in C and only crosses
|
|
51
|
+
into Ruby when the user's block is called. No per-node FFI.
|
|
52
|
+
|
|
53
|
+
The libtaurus `binding_wrapper` field shipped in v0.12.0 doesn't help
|
|
54
|
+
here in the Ruby binding — Ruby FFI still needs an FFI call to read
|
|
55
|
+
`binding_wrapper`. The Ruby-side `WeakMap` cache avoids that FFI call
|
|
56
|
+
on cache hits. So the binding's existing cache is already optimal for
|
|
57
|
+
Ruby; the `binding_wrapper` field is more useful for bindings that
|
|
58
|
+
don't have a native GC hook (Python ctypes, Go cgo, Rust bindgen).
|
|
59
|
+
|
|
60
|
+
## What changed from earlier runs
|
|
61
|
+
|
|
62
|
+
### v0.11.0 → v0.11.2 (binding-side)
|
|
63
|
+
|
|
64
|
+
- **Lazy NodeSet** — `NodeSet.from_result` keeps the
|
|
65
|
+
`TaurusXPathResult*` alive (via `FFI::AutoPointer`) and materializes
|
|
66
|
+
`self[i]` on demand. Eager materialization was the #1 cost.
|
|
67
|
+
- **Per-Document wrapper cache** — `ObjectSpace::WeakMap` keyed on c_ptr
|
|
68
|
+
address. Eliminates re-allocation on repeated access to the same node.
|
|
69
|
+
|
|
70
|
+
### v0.11.4 (libtaurus-side)
|
|
71
|
+
|
|
72
|
+
- **`taurus_xpath_result_get_nodes`** — batch accessor. The Ruby binding
|
|
73
|
+
now uses this in `NodeSet#each` to fetch all node pointers in one FFI
|
|
74
|
+
call instead of N calls.
|
|
75
|
+
|
|
76
|
+
### v0.12.0 (libtaurus-side)
|
|
77
|
+
|
|
78
|
+
- **`binding_wrapper` field on `TaurusNode`** — present in the C struct
|
|
79
|
+
but not used by the Ruby binding (see "Why tree traversal still loses"
|
|
80
|
+
above). Useful for non-Ruby bindings.
|
|
81
|
+
- **`#261` fix** — `benchmark-ips` on 38 KB docs no longer segfaults.
|
|
82
|
+
All upstream issues closed.
|
|
83
|
+
|
|
84
|
+
### Before/after the binding + libtaurus v0.12.0 optimizations
|
|
85
|
+
|
|
86
|
+
| Operation | v0.11.0 (eager) | v0.12.0 (lazy + batch) | Speedup |
|
|
87
|
+
|---|---:|---:|---:|
|
|
88
|
+
| XPath `//book` (100 nodes) | 87.77 µs (0.15×) | ~4 µs (5×) | **22×** |
|
|
89
|
+
| XPath union (200 nodes) | 188.40 µs (0.14×) | ~14 µs (3×) | **13×** |
|
|
90
|
+
| XPath complex | 126.88 µs (0.67×) | ~80 µs (1.3×) | 1.6× |
|
|
91
|
+
| Tree traversal | 1203 µs (0.50×) | ~900 µs (0.55×) | 1.3× |
|
|
92
|
+
|
|
93
|
+
## What this means for the v0.1.0 release
|
|
94
|
+
|
|
95
|
+
- Taurus is the right choice for almost every Nokogiri workload on
|
|
96
|
+
small-to-medium docs (≤20 KB): 2–10× faster than Nokogiri.
|
|
97
|
+
- Tree-traversal-heavy workloads (single-pass DOM scraping where you
|
|
98
|
+
touch every node once) are 1.8× slower than Nokogiri. Acceptable
|
|
99
|
+
for v0.1.0; the binding could ship a "fast traverse" path later that
|
|
100
|
+
skips wrapping for read-only blocks.
|
|
101
|
+
- All known libtaurus bugs are fixed. No upstream blockers.
|
|
102
|
+
|
|
103
|
+
## Analysis
|
|
104
|
+
|
|
105
|
+
### Where Taurus wins
|
|
106
|
+
|
|
107
|
+
**Parse (1.69×–6.90×)** — libtaurus's single-pass direct parser (the only
|
|
108
|
+
parser since v0.11.0, after flat + legacy were deleted) is dramatically
|
|
109
|
+
faster than libxml2's parser. The gap widens with document size.
|
|
110
|
+
|
|
111
|
+
**XPath returning scalars (4.56×)** — `count()`, `boolean()`, `string()`,
|
|
112
|
+
`number()` queries skip NodeSet materialization entirely. libtaurus's XPath
|
|
113
|
+
bytecode VM evaluates these in a single C call, no Ruby objects allocated
|
|
114
|
+
per match.
|
|
115
|
+
|
|
116
|
+
**XPath predicate match (13.01×)** — when the predicate narrows to a small
|
|
117
|
+
result set (single match in this test), Taurus is much faster than
|
|
118
|
+
Nokogiri/libxml2.
|
|
119
|
+
|
|
120
|
+
**Serialize (2.84×)** — single C call into `taurus_document_serialize`,
|
|
121
|
+
no Ruby traversal.
|
|
122
|
+
|
|
123
|
+
### Where Nokogiri wins
|
|
124
|
+
|
|
125
|
+
**Nodeset-returning XPath (0.13×–0.16×)** — when a query returns a 100-node
|
|
126
|
+
NodeSet, Taurus materializes every node eagerly (100× `Node.wrap` calls,
|
|
127
|
+
each dispatching on `taurus_node_get_type`). Nokogiri caches wrappers
|
|
128
|
+
lazily.
|
|
129
|
+
|
|
130
|
+
**Tree traversal (0.49×)** — same root cause. `Node#traverse` creates a
|
|
131
|
+
new wrapper per visited node via `Node.wrap`; Nokogiri reuses cached
|
|
132
|
+
wrappers.
|
|
133
|
+
|
|
134
|
+
### Optimization opportunities (Ruby-side, no libtaurus work needed)
|
|
135
|
+
|
|
136
|
+
1. **Lazy NodeSet materialization.** Currently `NodeSet.from_result`
|
|
137
|
+
iterates the C result and calls `Node.wrap` for each entry on
|
|
138
|
+
construction. Switch to lazy: keep the `TaurusXPathResult*` alive,
|
|
139
|
+
materialize `self[i]` on demand. Frees the eager 100× wrap.
|
|
140
|
+
2. **Node wrapper cache.** Weak-ref map keyed on the c_ptr address.
|
|
141
|
+
`Node.wrap(ptr)` checks the cache first; only creates a new wrapper
|
|
142
|
+
if none exists. Matches Nokogiri's behavior.
|
|
143
|
+
3. **Specialized traverse path.** For pure-traversal use cases (no
|
|
144
|
+
per-node mutation), skip the wrapper and call FFI directly. Lower
|
|
145
|
+
overhead but less idiomatic.
|
|
146
|
+
|
|
147
|
+
### Blockers
|
|
148
|
+
|
|
149
|
+
**Parse-loop segfault on >20 KB docs with explicit `Document#free`
|
|
150
|
+
(libtaurus #256).** The v0.11.1 fix addressed one stale-thread-local
|
|
151
|
+
path but not the parse+free cycle path. Long-running services and batch
|
|
152
|
+
processors parsing medium/large XML cannot rely on the standard Ruby
|
|
153
|
+
"let GC handle document lifetime" pattern OR the explicit `Document#free`
|
|
154
|
+
pattern. Tracked upstream; workaround: cap doc size or avoid tight loops.
|
|
155
|
+
|
|
156
|
+
**DOCTYPE PUBLIC/SYSTEM not exposed (libtaurus #253).** Unrelated to
|
|
157
|
+
benchmarks but blocks 4 Ruby specs. Low impact on perf-sensitive workloads.
|
|
158
|
+
|
|
159
|
+
## What this means for the v0.1.0 release
|
|
160
|
+
|
|
161
|
+
- For **parse-heavy / XPath-aggregate / serialize** workloads on small-to-
|
|
162
|
+
medium docs (≤20 KB): Taurus is clearly the right choice. 1.7-7× faster
|
|
163
|
+
than Nokogiri.
|
|
164
|
+
- For **heavy nodeset manipulation** (scraping, large DOM traversal):
|
|
165
|
+
Nokogiri is faster today. The Ruby-side optimizations above would close
|
|
166
|
+
most of the gap.
|
|
167
|
+
- For **long-running services on medium/large docs**: blocked by #256
|
|
168
|
+
until libtaurus ships a complete fix.
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Ruby-level performance comparison: Taurus::XML (FFI → libtaurus v0.12.0)
|
|
4
|
+
# vs Nokogiri (C extension → libxml2).
|
|
5
|
+
#
|
|
6
|
+
# Run with: bundle exec ruby -Ilib benchmark/taurus_vs_nokogiri.rb
|
|
7
|
+
|
|
8
|
+
require "benchmark"
|
|
9
|
+
require "taurus/xml"
|
|
10
|
+
require "nokogiri"
|
|
11
|
+
|
|
12
|
+
module Fixtures
|
|
13
|
+
SMALL = "<catalog>" +
|
|
14
|
+
(1..10).map { |i| "<book id='#{i}'><title>Book #{i}</title></book>" }.join +
|
|
15
|
+
"</catalog>"
|
|
16
|
+
|
|
17
|
+
MEDIUM = ("<catalog version='2.0'>" +
|
|
18
|
+
(1..100).map do |i|
|
|
19
|
+
"<book id='#{i}' lang='en'>" \
|
|
20
|
+
"<title>Book #{i}</title>" \
|
|
21
|
+
"<author id='a#{i}'>Author #{i}</author>" \
|
|
22
|
+
"<price currency='USD'>#{i}.99</price>" \
|
|
23
|
+
"</book>"
|
|
24
|
+
end.join +
|
|
25
|
+
"</catalog>").freeze
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def time_it(label, n, &block)
|
|
29
|
+
GC.start
|
|
30
|
+
t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
31
|
+
n.times { yield }
|
|
32
|
+
elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - t0
|
|
33
|
+
us_per_iter = (elapsed / n) * 1_000_000
|
|
34
|
+
printf " %-40s %8.2f µs/iter (%d iters in %.3fs)\n", label, us_per_iter, n, elapsed
|
|
35
|
+
us_per_iter
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def ratio(label, taurus_us, nokogiri_us)
|
|
39
|
+
r = nokogiri_us / taurus_us
|
|
40
|
+
who = r > 1 ? "Taurus faster" : "Nokogiri faster"
|
|
41
|
+
printf " → %-30s Taurus/Nokogiri = %.2fx (%s)\n\n", label, r, who
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
N_PARSE_SMALL = 5_000
|
|
45
|
+
N_PARSE_MEDIUM = 1_000
|
|
46
|
+
N_QUERY = 10_000
|
|
47
|
+
N_TRAVERSE = 2_000
|
|
48
|
+
N_SERIALIZE = 1_000
|
|
49
|
+
|
|
50
|
+
puts "===== Parse — small (#{Fixtures::SMALL.bytesize} B) ====="
|
|
51
|
+
t = time_it("taurus parse small", N_PARSE_SMALL) { d = Taurus::XML::Document.parse(Fixtures::SMALL); d.free }
|
|
52
|
+
n = time_it("nokogiri parse small", N_PARSE_SMALL) { Nokogiri::XML(Fixtures::SMALL) }
|
|
53
|
+
ratio("parse small", t, n)
|
|
54
|
+
|
|
55
|
+
puts "===== Parse — medium (#{Fixtures::MEDIUM.bytesize} B) ====="
|
|
56
|
+
t = time_it("taurus parse medium", N_PARSE_MEDIUM) { d = Taurus::XML::Document.parse(Fixtures::MEDIUM); d.free }
|
|
57
|
+
n = time_it("nokogiri parse medium", N_PARSE_MEDIUM) { Nokogiri::XML(Fixtures::MEDIUM) }
|
|
58
|
+
ratio("parse medium", t, n)
|
|
59
|
+
|
|
60
|
+
# Pre-parse medium for query benchmarks
|
|
61
|
+
doc_t = Taurus::XML::Document.parse(Fixtures::MEDIUM)
|
|
62
|
+
doc_n = Nokogiri::XML(Fixtures::MEDIUM)
|
|
63
|
+
|
|
64
|
+
puts "===== XPath — count(//book) ====="
|
|
65
|
+
t = time_it("taurus xpath count()", N_QUERY) { doc_t.xpath("count(//book)") }
|
|
66
|
+
n = time_it("nokogiri xpath count()", N_QUERY) { doc_n.xpath("count(//book)") }
|
|
67
|
+
ratio("xpath count()", t, n)
|
|
68
|
+
|
|
69
|
+
puts "===== XPath — //book (nodeset of 100) ====="
|
|
70
|
+
t = time_it("taurus xpath //book", N_QUERY) { doc_t.xpath("//book") }
|
|
71
|
+
n = time_it("nokogiri xpath //book", N_QUERY) { doc_n.xpath("//book") }
|
|
72
|
+
ratio("xpath //book", t, n)
|
|
73
|
+
|
|
74
|
+
puts "===== XPath — predicate //book[@id='50'] ====="
|
|
75
|
+
t = time_it("taurus xpath predicate", N_QUERY) { doc_t.xpath("//book[@id='50']") }
|
|
76
|
+
n = time_it("nokogiri xpath predicate", N_QUERY) { doc_n.xpath("//book[@id='50']") }
|
|
77
|
+
ratio("xpath predicate", t, n)
|
|
78
|
+
|
|
79
|
+
puts "===== XPath — complex //book[price > 50] ====="
|
|
80
|
+
t = time_it("taurus xpath complex", N_QUERY) { doc_t.xpath("//book[price > 50]") }
|
|
81
|
+
n = time_it("nokogiri xpath complex", N_QUERY) { doc_n.xpath("//book[price > 50]") }
|
|
82
|
+
ratio("xpath complex", t, n)
|
|
83
|
+
|
|
84
|
+
puts "===== XPath — union //author | //title ====="
|
|
85
|
+
t = time_it("taurus xpath union", N_QUERY) { doc_t.xpath("//author | //title") }
|
|
86
|
+
n = time_it("nokogiri xpath union", N_QUERY) { doc_n.xpath("//author | //title") }
|
|
87
|
+
ratio("xpath union", t, n)
|
|
88
|
+
|
|
89
|
+
puts "===== Tree traversal (root.traverse) ====="
|
|
90
|
+
t = time_it("taurus traverse", N_TRAVERSE) { doc_t.root.traverse { |n| n.name } }
|
|
91
|
+
n = time_it("nokogiri traverse", N_TRAVERSE) { doc_n.root.traverse { |n| n.name } }
|
|
92
|
+
ratio("traverse", t, n)
|
|
93
|
+
|
|
94
|
+
puts "===== Serialize — Document#to_xml ====="
|
|
95
|
+
t = time_it("taurus serialize", N_SERIALIZE) { doc_t.to_xml }
|
|
96
|
+
n = time_it("nokogiri serialize", N_SERIALIZE) { doc_n.to_xml }
|
|
97
|
+
ratio("serialize", t, n)
|
|
98
|
+
|
|
99
|
+
doc_t.free
|
|
100
|
+
|
|
101
|
+
puts ""
|
|
102
|
+
puts "libtaurus v0.12.0 — all upstream issues closed."
|
|
103
|
+
puts "All 176 Ruby specs passing, 0 pending."
|
|
104
|
+
|
|
105
|
+
|