rusteerio 1.1.2 → 1.1.4

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.
package/Cargo.lock CHANGED
@@ -720,7 +720,7 @@ dependencies = [
720
720
 
721
721
  [[package]]
722
722
  name = "rusteerio"
723
- version = "1.1.2"
723
+ version = "1.1.4"
724
724
  dependencies = [
725
725
  "ego-tree",
726
726
  "html5ever",
package/Cargo.toml CHANGED
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "rusteerio"
3
- version = "1.1.2"
3
+ version = "1.1.4"
4
4
  edition = "2021"
5
5
  description = "Rust HTML5 parser and CSS selector engine for the rusteerio Node.js package"
6
6
  license = "MIT"
package/README.md CHANGED
@@ -198,18 +198,18 @@ Rusteerio was faster in **all 45 cases**. Representative results:
198
198
 
199
199
  | Workload | CPUs | Cheerio | rusteerio | rusteerio |
200
200
  | --- | ---: | ---: | ---: | --- |
201
- | Load/query, 500 articles | 1 | 36.53 ms | 3.37 ms | 10.82× faster |
202
- | Load/query, 5,000 articles | 1 | 152.78 ms | 34.90 ms | 4.38× faster |
203
- | HTML mutations, 5,000 articles | 1 | 241.28 ms | 68.12 ms | 3.54× faster |
204
- | XML load/query, 5,000 articles | 1 | 65.28 ms | 15.24 ms | 4.28× faster |
205
- | Extract 16 documents × 5,000 articles | 1 | 2,874.89 ms | 447.12 ms | 6.43× faster |
206
- | Extract 16 documents × 5,000 articles | 8 | 805.16 ms | 122.93 ms | 6.55× faster |
207
- | Nested callback extraction, 8 × 500 articles | 8 | 54.17 ms | 33.09 ms | 1.64× faster |
208
- | Cold extraction, 16 × 20 articles | 8 | 15.19 ms | 3.17 ms | 4.79× faster |
209
- | 8 concurrent batches × 8 × 300 articles | 8 | 127.82 ms | 23.94 ms | 5.34× faster |
210
- | HTML stream, 50,000 elements | 1 | 1,086.17 ms | 297.04 ms | 3.66× faster |
211
- | XML stream, 50,000 elements | 8 | 122.06 ms | 67.44 ms | 1.81× faster |
212
- | Append/update/remove, 4,000 cycles | 1 | 613.95 ms | 388.99 ms | 1.58× faster |
201
+ | Load/query, 500 articles | 1 | 41.89 ms | 3.77 ms | 11.11× faster |
202
+ | Load/query, 5,000 articles | 1 | 184.64 ms | 41.27 ms | 4.47× faster |
203
+ | HTML mutations, 5,000 articles | 1 | 308.55 ms | 89.40 ms | 3.45× faster |
204
+ | XML load/query, 5,000 articles | 1 | 90.55 ms | 20.00 ms | 4.53× faster |
205
+ | Extract 16 documents × 5,000 articles | 1 | 3,615.68 ms | 557.12 ms | 6.49× faster |
206
+ | Extract 16 documents × 5,000 articles | 8 | 1,103.64 ms | 178.32 ms | 6.19× faster |
207
+ | Nested callback extraction, 8 × 500 articles | 8 | 76.75 ms | 42.52 ms | 1.81× faster |
208
+ | Cold extraction, 16 × 20 articles | 8 | 22.66 ms | 5.65 ms | 4.01× faster |
209
+ | 8 concurrent batches × 8 × 300 articles | 8 | 153.41 ms | 26.38 ms | 5.81× faster |
210
+ | HTML stream, 50,000 elements | 1 | 1,551.73 ms | 384.28 ms | 4.04× faster |
211
+ | XML stream, 50,000 elements | 8 | 168.84 ms | 108.97 ms | 1.55× faster |
212
+ | Append/update/remove, 4,000 cycles | 1 | 1,143.37 ms | 766.40 ms | 1.49× faster |
213
213
 
214
214
  Cold measurements include engine import, pool initialization and the first
215
215
  batch.
@@ -225,14 +225,14 @@ HTML. Walking the same nodes without enumerating attributes is faster in both.
225
225
  Very small pages can measure either way.
226
226
 
227
227
  **Memory.** A batch extracting 16 documents of 5,000 articles with eight CPUs
228
- ended at 581 MB RSS for rusteerio and 2,497 MB for Cheerio's worker pool, the
229
- HTML stream of 50,000 elements at 491 MB versus 666 MB, and the large XML
230
- load/query at 140 MB versus 147 MB. The XML stream ended higher, at 285 MB versus
231
- 221 MB, and the append/update/remove workload about even (93 MB versus 91 MB).
232
- Linux builds use mimalloc, which keeps freed pages briefly for reuse; setting
233
- `MIMALLOC_PURGE_DELAY=0` returns them at once and lowers stream RSS, at a cost in
234
- speed for later documents. RSS includes initialization and resident input, and
235
- sampled peaks do not establish a universal memory bound.
228
+ ended at 581 MB RSS for rusteerio and 2,523 MB for Cheerio's worker pool, the
229
+ HTML stream of 50,000 elements at 364 MB versus 666 MB, and the large XML
230
+ load/query at 140 MB versus 148 MB. The XML stream ended higher, at 236 MB versus
231
+ 221 MB, and the append/update/remove workload about even (94 MB versus 90 MB).
232
+ Linux builds use mimalloc, which keeps freed pages briefly for reuse; after a
233
+ parse that builds at least 100,000 nodes, the parsing thread returns them to the
234
+ system. RSS includes initialization and resident input, and sampled peaks do not
235
+ establish a universal memory bound.
236
236
 
237
237
  These results describe this VM and revision; hardware, runtime and input shape
238
238
  affect them. See [the results](bench/results/2026-10-14/) and
@@ -4,7 +4,7 @@ This file preserves third-party license and attribution notices for the Rust nat
4
4
 
5
5
  Generated by `node scripts/generate-notices.mjs` from `cargo metadata --locked`, crate license files, and the pinned supplemental sources recorded in `scripts/notices/sources.json`. It includes build dependencies and dependencies for all target platforms; a particular binary may use a subset. Identical notice texts are reproduced once and referenced below. The html5ever, markup5ever and html5gum crates contain local observer-hook and opt-in UTF-16 transport patches; their complete patched source and original notices are included in `native/vendor`. Other dependency source files are unmodified.
6
6
 
7
- Cargo.lock SHA-256: `b834f3bb9a4bb9e7889f20f789f80af645271bcfd0057cf4217843b17e3f5ae5`.
7
+ Cargo.lock SHA-256: `a2ff4645db9ecfd1110ed1ae3af80a43ffaf87ab3c192ce24bee23065684331c`.
8
8
 
9
9
  ## Source availability
10
10
 
package/native/src/lib.rs CHANGED
@@ -266,6 +266,7 @@ impl NativeDocument {
266
266
  if arena.nodes.len() > POOL_RELEASE_NODES {
267
267
  runtime::start_release_thread();
268
268
  }
269
+ runtime::return_parse_pages(arena.nodes.len());
269
270
  Self {
270
271
  html: RefCell::new(None),
271
272
  ids: RefCell::new(Vec::new()),
@@ -516,6 +517,7 @@ impl NativeDocument {
516
517
  if ids.len() > POOL_RELEASE_NODES {
517
518
  runtime::start_release_thread();
518
519
  }
520
+ runtime::return_parse_pages(ids.len());
519
521
  Self {
520
522
  html: RefCell::new(Some(html)),
521
523
  ids: RefCell::new(ids),
@@ -183,6 +183,29 @@ static RELEASE_THREAD: OnceLock<mpsc::Sender<Box<dyn Send>>> = OnceLock::new();
183
183
  /// exists, so freeing large trees does not stall JavaScript. Documents large
184
184
  /// enough to be released later start it when they are created, because
185
185
  /// finalizers must not create threads.
186
+ /// Trees with at least this many nodes return the parse's freed memory to
187
+ /// the system once built.
188
+ #[cfg(target_os = "linux")]
189
+ const RETURN_PAGES_NODES: usize = 100_000;
190
+
191
+ #[cfg(target_os = "linux")]
192
+ extern "C" {
193
+ fn mi_collect(force: bool);
194
+ }
195
+
196
+ /// Building a large tree grows and then frees big buffers, such as a node
197
+ /// vector that doubled several times. mimalloc keeps freed pages resident for
198
+ /// reuse; after a large parse, return them on the parsing thread instead.
199
+ pub(crate) fn return_parse_pages(nodes: usize) {
200
+ #[cfg(target_os = "linux")]
201
+ if nodes >= RETURN_PAGES_NODES {
202
+ // SAFETY: mimalloc's own collection frees only memory no block uses.
203
+ unsafe { mi_collect(true) };
204
+ }
205
+ #[cfg(not(target_os = "linux"))]
206
+ let _ = nodes;
207
+ }
208
+
186
209
  pub(crate) fn start_release_thread() {
187
210
  RELEASE_THREAD.get_or_init(|| {
188
211
  let (sender, receiver) = mpsc::channel::<Box<dyn Send>>();
@@ -49,41 +49,128 @@ const KINDS: [&str; 8] = [
49
49
  const SHORT_NAME_SLOTS: usize = 256;
50
50
 
51
51
  pub(crate) struct NodeSnapshot {
52
+ /// Consecutive ranges of nodes, encoded separately (in parallel for
53
+ /// large trees) with references local to each chunk.
54
+ chunks: Vec<Chunk>,
55
+ }
56
+
57
+ struct Chunk {
52
58
  is_encoded: bool,
53
59
  nodes: Vec<u32>,
54
60
  children: Vec<u32>,
55
61
  attributes: Vec<u32>,
56
62
  strings: Vec<u32>,
57
63
  text: String,
64
+ units: u32,
58
65
  }
59
66
 
60
67
  impl NodeSnapshot {
61
68
  pub(crate) fn into_js(self, env: &Env) -> Result<JsObject> {
62
- let parts = [&self.nodes, &self.children, &self.attributes, &self.strings];
63
- let lengths = parts.map(|part| part.len() as u32);
69
+ let total = |part: fn(&Chunk) -> usize| self.chunks.iter().map(part).sum::<usize>();
70
+ let lengths = [
71
+ total(|chunk| chunk.nodes.len()),
72
+ total(|chunk| chunk.children.len()),
73
+ total(|chunk| chunk.attributes.len()),
74
+ total(|chunk| chunk.strings.len()),
75
+ ];
64
76
  let header = [
65
- lengths[0],
66
- lengths[1],
67
- lengths[2],
68
- lengths[3],
69
- u32::from(self.is_encoded),
77
+ lengths[0] as u32,
78
+ lengths[1] as u32,
79
+ lengths[2] as u32,
80
+ lengths[3] as u32,
81
+ u32::from(self.chunks.iter().any(|chunk| chunk.is_encoded)),
70
82
  ];
71
- let length = header.len() + parts.iter().map(|part| part.len()).sum::<usize>();
83
+ let length = header.len() + lengths.iter().sum::<usize>();
72
84
  let data = uninitialized_words(env, length, |words| {
73
- let mut offset = 0;
74
- for part in std::iter::once(&header[..]).chain(parts.iter().map(|part| part.as_slice()))
75
- {
76
- words[offset..offset + part.len()].copy_from_slice(part);
77
- offset += part.len();
85
+ words[..header.len()].copy_from_slice(&header);
86
+ let (nodes, rest) = words[header.len()..].split_at_mut(lengths[0]);
87
+ let (children, rest) = rest.split_at_mut(lengths[1]);
88
+ let (attributes, strings) = rest.split_at_mut(lengths[2]);
89
+ let mut bases = Bases::default();
90
+ for chunk in &self.chunks {
91
+ bases.copy(chunk, nodes, children, attributes, strings);
78
92
  }
79
93
  })?;
94
+ let text = if self.chunks.len() == 1 {
95
+ crate::codec::js_string(env, &self.chunks[0].text)?
96
+ } else {
97
+ let mut text =
98
+ String::with_capacity(self.chunks.iter().map(|chunk| chunk.text.len()).sum());
99
+ for chunk in &self.chunks {
100
+ text.push_str(&chunk.text);
101
+ }
102
+ crate::codec::js_string(env, &text)?
103
+ };
80
104
  let mut object = env.create_object()?;
81
105
  object.set_named_property("data", data)?;
82
- object.set_named_property("text", crate::codec::js_string(env, &self.text)?)?;
106
+ object.set_named_property("text", text)?;
83
107
  Ok(object)
84
108
  }
85
109
  }
86
110
 
111
+ /// Where the next chunk's entries start in each combined list.
112
+ #[derive(Default)]
113
+ struct Bases {
114
+ nodes: usize,
115
+ children: usize,
116
+ attributes: usize,
117
+ strings: usize,
118
+ units: u32,
119
+ }
120
+
121
+ impl Bases {
122
+ /// Copies `chunk` after the chunks before it, shifting its references to
123
+ /// children, attribute pairs, strings and text by theirs.
124
+ fn copy(
125
+ &mut self,
126
+ chunk: &Chunk,
127
+ nodes: &mut [u32],
128
+ children: &mut [u32],
129
+ attributes: &mut [u32],
130
+ strings: &mut [u32],
131
+ ) {
132
+ let string_base = (self.strings / 2) as u32;
133
+ let child_base = self.children as u32;
134
+ let pair_base = (self.attributes / 2) as u32;
135
+ let target = &mut nodes[self.nodes..self.nodes + chunk.nodes.len()];
136
+ target.copy_from_slice(&chunk.nodes);
137
+ if self.nodes != 0 {
138
+ for fields in target.as_chunks_mut::<STRIDE>().0 {
139
+ let flags = fields[10];
140
+ if flags & RELEASED != 0 {
141
+ continue;
142
+ }
143
+ fields[0] += string_base;
144
+ fields[4] += child_base;
145
+ fields[8] += pair_base;
146
+ for field in [6, 7, 14] {
147
+ if fields[field] != 0 {
148
+ fields[field] += string_base;
149
+ }
150
+ }
151
+ if flags & HAS_NAMESPACE != 0 {
152
+ fields[13] += string_base;
153
+ }
154
+ }
155
+ }
156
+ children[self.children..self.children + chunk.children.len()]
157
+ .copy_from_slice(&chunk.children);
158
+ let target = &mut attributes[self.attributes..self.attributes + chunk.attributes.len()];
159
+ for (slot, reference) in target.iter_mut().zip(&chunk.attributes) {
160
+ *slot = reference + string_base;
161
+ }
162
+ let target = &mut strings[self.strings..self.strings + chunk.strings.len()];
163
+ for (slot, offset) in target.iter_mut().zip(&chunk.strings) {
164
+ *slot = offset + self.units;
165
+ }
166
+ self.nodes += chunk.nodes.len();
167
+ self.children += chunk.children.len();
168
+ self.attributes += chunk.attributes.len();
169
+ self.strings += chunk.strings.len();
170
+ self.units += chunk.units;
171
+ }
172
+ }
173
+
87
174
  /// A `Uint32Array` of `length` words that `fill` writes. A Node buffer is
88
175
  /// used for its storage because, unlike an `ArrayBuffer`, it is not
89
176
  /// zero-filled first; `fill` writes every word before JavaScript can read it.
@@ -167,43 +254,34 @@ struct Builder {
167
254
  is_encoded: bool,
168
255
  }
169
256
 
257
+ /// Nodes per chunk worth encoding on another thread.
258
+ const PARALLEL_CHUNK_NODES: usize = 8192;
259
+
170
260
  pub(crate) fn build(document: &NativeDocument) -> NodeSnapshot {
171
261
  let codec = document.options.get("__utf16Codec") == Some(&Value::Bool(true))
172
262
  || document.options.get("__losslessUnicode") == Some(&Value::Bool(true));
173
- let count = document.slot_count() as usize;
174
- let mut builder = Builder {
175
- nodes: Vec::with_capacity(count * STRIDE),
176
- children: Vec::with_capacity(count),
177
- attributes: Vec::new(),
178
- strings: Vec::with_capacity(count * 2),
179
- text: String::with_capacity(document.content_bytes.get()),
180
- units: 0,
181
- interned: IdMap::with_capacity_and_hasher(32, Default::default()),
182
- kinds: [None; KINDS.len()],
183
- short_names: Box::new([(0, 0, 0); SHORT_NAME_SLOTS]),
184
- local_names: IdMap::with_capacity_and_hasher(32, Default::default()),
185
- namespaces: IdMap::default(),
186
- codec,
187
- is_encoded: false,
188
- };
189
263
  if let Some(arena) = document.arena.borrow().as_ref() {
190
- builder.nodes.reserve(arena.nodes.len() * STRIDE);
191
- for id in 0..arena.nodes.len() {
192
- match arena.node(id as u32) {
193
- Some(node) => builder.encode(node),
194
- None => builder.released(),
195
- }
196
- }
197
- } else {
198
- builder.encode_immutable(document);
264
+ let chunks =
265
+ crate::runtime::chunked_map(arena.nodes.len(), PARALLEL_CHUNK_NODES, |range| {
266
+ let mut builder = Builder::new(range.len(), 0, codec);
267
+ for id in range {
268
+ match arena.node(id as u32) {
269
+ Some(node) => builder.encode(node),
270
+ None => builder.released(),
271
+ }
272
+ }
273
+ vec![builder.finish()]
274
+ });
275
+ return NodeSnapshot { chunks };
199
276
  }
277
+ let mut builder = Builder::new(
278
+ document.slot_count() as usize,
279
+ document.content_bytes.get(),
280
+ codec,
281
+ );
282
+ builder.encode_immutable(document);
200
283
  NodeSnapshot {
201
- is_encoded: builder.is_encoded,
202
- nodes: builder.nodes,
203
- children: builder.children,
204
- attributes: builder.attributes,
205
- strings: builder.strings,
206
- text: builder.text,
284
+ chunks: vec![builder.finish()],
207
285
  }
208
286
  }
209
287
 
@@ -232,6 +310,36 @@ fn pointer(value: Option<u32>) -> u32 {
232
310
  }
233
311
 
234
312
  impl Builder {
313
+ fn new(count: usize, text_bytes: usize, codec: bool) -> Self {
314
+ Self {
315
+ nodes: Vec::with_capacity(count * STRIDE),
316
+ children: Vec::with_capacity(count),
317
+ attributes: Vec::new(),
318
+ strings: Vec::with_capacity(count * 2),
319
+ text: String::with_capacity(text_bytes),
320
+ units: 0,
321
+ interned: IdMap::with_capacity_and_hasher(32, Default::default()),
322
+ kinds: [None; KINDS.len()],
323
+ short_names: Box::new([(0, 0, 0); SHORT_NAME_SLOTS]),
324
+ local_names: IdMap::with_capacity_and_hasher(32, Default::default()),
325
+ namespaces: IdMap::default(),
326
+ codec,
327
+ is_encoded: false,
328
+ }
329
+ }
330
+
331
+ fn finish(self) -> Chunk {
332
+ Chunk {
333
+ is_encoded: self.is_encoded,
334
+ nodes: self.nodes,
335
+ children: self.children,
336
+ attributes: self.attributes,
337
+ strings: self.strings,
338
+ text: self.text,
339
+ units: self.units,
340
+ }
341
+ }
342
+
235
343
  fn string(&mut self, value: &str) -> u32 {
236
344
  let start = self.units;
237
345
  self.text.push_str(value);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rusteerio",
3
- "version": "1.1.2",
3
+ "version": "1.1.4",
4
4
  "description": "Cheerio-compatible HTML processing with a Rust engine and parallel, non-blocking extraction",
5
5
  "license": "MIT",
6
6
  "repository": {
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file