ractor-pipeline 0.2.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/LICENSE.txt +21 -0
- data/README.md +367 -0
- data/Rakefile +12 -0
- data/examples/demo.rb +138 -0
- data/examples/logstats.rb +114 -0
- data/examples/perf.rb +184 -0
- data/examples/readme_bench.rb +129 -0
- data/examples/vs_parallel.rb +116 -0
- data/lib/ractor/pipeline/version.rb +7 -0
- data/lib/ractor/pipeline.rb +462 -0
- data/sig/ractor/pipeline.rbs +6 -0
- metadata +56 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 621d7215c299abc1fbe5da7396b259e3ac77d73a04d38d1195a13dac0ea0aea2
|
|
4
|
+
data.tar.gz: 146547cf104500b5907af8e5629f5ac18344b0a294c61c3eacaba3ebdfec363f
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: f4859558ee7d05ed135ad67bed0af8394078ff55676d5003700a5cfa792d697bacd49d65656b25544a9f6cf9599be649daaf66459b35a241f3633018512841aa
|
|
7
|
+
data.tar.gz: 3961a0667e933e77bdb4f1108426eae47b5fb1becc16661edcc55ec1c425b1f5d589a64f2946870af0deec889b2bc2b902519aee27497d65ed16c4a22dbe2539
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Koichi Sasada
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
# Ractor::Pipeline
|
|
2
|
+
|
|
3
|
+
A DSL to build stream processing pipelines with Ractors. The basic model is
|
|
4
|
+
close to a Unix shell pipeline: `stream` produces an input stream, each
|
|
5
|
+
`pipe`/`filter_pipe` stage is a persistent Ractor connected by
|
|
6
|
+
`Ractor::Port`s, and terminal operations such as `reduce` consume the output
|
|
7
|
+
in the caller Ractor.
|
|
8
|
+
|
|
9
|
+
Unlike `enum.map{}.filter{}`, which describes only data operations, this DSL
|
|
10
|
+
intentionally couples data operations with the execution topology: what you
|
|
11
|
+
see in the code is the Ractor graph that runs.
|
|
12
|
+
|
|
13
|
+
```ruby
|
|
14
|
+
require "ractor/pipeline"
|
|
15
|
+
include Ractor::Pipeline
|
|
16
|
+
|
|
17
|
+
# conceptually: cat FILE | grep foo | wc
|
|
18
|
+
stream(File.foreach(name)).
|
|
19
|
+
filter_pipe(lanes: 4){ it.include?("foo") }.
|
|
20
|
+
reduce([0, 0, 0]) do |(lines, words, bytes), line|
|
|
21
|
+
[lines + 1, words + line.scan(/\S+/).size, bytes + line.bytesize]
|
|
22
|
+
end
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
```text
|
|
26
|
+
File.foreach ──> filter Ractor x4 ──> Port ──> reduce in caller
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Key properties:
|
|
30
|
+
|
|
31
|
+
* **1 stage = `lanes:` persistent Ractors** — workers process many
|
|
32
|
+
elements; no Ractor is created per element.
|
|
33
|
+
* **Demand-driven scheduling** — a multi-lane stage is fed by pull: a
|
|
34
|
+
worker gets a new batch only when it asks for one, so a stuck worker
|
|
35
|
+
never has work piling up behind it, and a fast or infinite source is
|
|
36
|
+
read only as fast as the pipeline consumes it.
|
|
37
|
+
* **Transparent batching** — `stream(src, batch: 1000)` packs 1000
|
|
38
|
+
elements per message to amortize the per-message cost; stage blocks
|
|
39
|
+
still see single elements.
|
|
40
|
+
|
|
41
|
+
Requires Ruby 4.0+ (`Ractor::Port`, `Ractor.shareable_proc`). The Ractor API
|
|
42
|
+
is experimental, and so is this library.
|
|
43
|
+
|
|
44
|
+
## Overview
|
|
45
|
+
|
|
46
|
+
| DSL | Enumerable analogue | topology |
|
|
47
|
+
|---|---|---|
|
|
48
|
+
| `stream(src, batch: k)` | `src.each` | source (fed from the caller) |
|
|
49
|
+
| `stream1(obj)` | `[obj].each` | single-element source |
|
|
50
|
+
| `.pipe{ f(it) }` | `map` | 1 persistent Ractor |
|
|
51
|
+
| `.pipe(lanes: n){}` | `map` | n Ractors, demand-driven, unordered |
|
|
52
|
+
| `.filter_pipe{ pred(it) }` | `filter` | 1 Ractor (sends the original element) |
|
|
53
|
+
| `.flat_pipe{ enum }` | `flat_map` | 1 input -> N outputs |
|
|
54
|
+
| `.tee(pipe{}, pipe{})` | — | broadcast to branches, merged output |
|
|
55
|
+
| `.reduce(init){}` `.each{}` `.to_a` `.count` `.first(n)` | same | terminal, runs in the caller, no Ractor |
|
|
56
|
+
|
|
57
|
+
## API guide
|
|
58
|
+
|
|
59
|
+
`include Ractor::Pipeline` makes the vocabulary available
|
|
60
|
+
(`Ractor::Pipeline.stream(...)` also works). Stage-building methods return
|
|
61
|
+
the pipeline object, so they chain; nothing runs until a terminal operation
|
|
62
|
+
is called.
|
|
63
|
+
|
|
64
|
+
### `stream(source, batch: 1)`
|
|
65
|
+
|
|
66
|
+
Creates a pipeline whose input is each element of `source` (anything that
|
|
67
|
+
responds to `each`). Elements are fed from a background Thread in the
|
|
68
|
+
caller Ractor, concurrently with the terminal operation, so producing and
|
|
69
|
+
consuming overlap.
|
|
70
|
+
|
|
71
|
+
```ruby
|
|
72
|
+
stream([1, 2, 3]) # three elements
|
|
73
|
+
stream(1..) # infinite stream (terminate with .first etc.)
|
|
74
|
+
stream(File.foreach(name)) # one element per line
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
`batch: k` packs k elements into one message. This is transparent — stage
|
|
78
|
+
blocks still receive one element at a time — and it amortizes the
|
|
79
|
+
per-message cost (sync + copy + envelope) over k elements, which matters
|
|
80
|
+
whenever the work per element is small (see
|
|
81
|
+
[Measured performance](#measured-performance)). Batches shrink through
|
|
82
|
+
`filter_pipe` and are re-split to `<= k` after `flat_pipe`; the message
|
|
83
|
+
*count* is unchanged, so the amortization survives filtering.
|
|
84
|
+
|
|
85
|
+
When the first stage is multi-lane, the source is read on demand: the
|
|
86
|
+
feeder only reads ahead by the workers' open demand tokens, so
|
|
87
|
+
`stream(huge_or_infinite_source)` does not balloon memory.
|
|
88
|
+
|
|
89
|
+
`stream1(obj)` is a shorthand for `stream([obj])`: it flows `obj` as a
|
|
90
|
+
single element, even when `obj` itself is each-able.
|
|
91
|
+
|
|
92
|
+
```ruby
|
|
93
|
+
stream1(config).pipe{ build(it) }.first
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
### `pipe(lanes: 1){ block }`
|
|
97
|
+
|
|
98
|
+
A processing stage: applies the block to each element and sends the return
|
|
99
|
+
value downstream. The current element is `it`.
|
|
100
|
+
|
|
101
|
+
```ruby
|
|
102
|
+
stream([1, 2, 3]).pipe{ it * 2 }.to_a #=> [2, 4, 6]
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
With `lanes: n`, the stage becomes n worker Ractors and the boundary into
|
|
106
|
+
it becomes demand-driven: each worker grants a small number of demand
|
|
107
|
+
tokens (2 per producer) and producers send a batch only to a worker they
|
|
108
|
+
hold a token for. A worker that is stuck on an expensive element simply
|
|
109
|
+
stops granting tokens, so the work is distributed by actual availability,
|
|
110
|
+
not round-robin. Completion order is not preserved (see
|
|
111
|
+
[Ordering](#ordering)).
|
|
112
|
+
|
|
113
|
+
```ruby
|
|
114
|
+
stream(rows).pipe(lanes: 8){ expensive(it) }
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
Consecutive stages form a pipeline and run concurrently — while stage 2
|
|
118
|
+
processes element k, stage 1 processes element k+1:
|
|
119
|
+
|
|
120
|
+
```ruby
|
|
121
|
+
stream(src).pipe{ parse(it) }.pipe{ format(it) }
|
|
122
|
+
# src ──> Ractor(parse) ──> Ractor(format) ──> ...
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
Boundaries into a single consumer (a `lanes: 1` stage, or the terminal
|
|
126
|
+
operation) are plain push over the port's FIFO: no demand round-trip, no
|
|
127
|
+
reordering.
|
|
128
|
+
|
|
129
|
+
### `filter_pipe(lanes: 1){ block }`
|
|
130
|
+
|
|
131
|
+
Like `pipe`, but the block is a predicate: the *original element* (not the
|
|
132
|
+
block's return value) is sent downstream iff the block returns truthy.
|
|
133
|
+
|
|
134
|
+
```ruby
|
|
135
|
+
stream(1..10).filter_pipe{ it.even? }.to_a #=> [2, 4, 6, 8, 10]
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
### `flat_pipe(lanes: 1){ block }`
|
|
139
|
+
|
|
140
|
+
The block returns an each-able object; each of its elements is sent
|
|
141
|
+
downstream individually (1 input -> N outputs). Useful for feeding many
|
|
142
|
+
files into one fixed-size Ractor graph:
|
|
143
|
+
|
|
144
|
+
```ruby
|
|
145
|
+
stream(file_names).
|
|
146
|
+
flat_pipe{ File.foreach(it) }. # 1 file -> N lines
|
|
147
|
+
filter_pipe(lanes: 4){ it.include?("Ractor") }.
|
|
148
|
+
count
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
### `tee(branch, branch, ...)`
|
|
152
|
+
|
|
153
|
+
Broadcasts each element to *every* branch (not load balancing). Branches
|
|
154
|
+
are receiver-less fragments; their outputs are merged, unordered, into one
|
|
155
|
+
downstream stream. Non-shareable elements are copied once per branch.
|
|
156
|
+
|
|
157
|
+
```ruby
|
|
158
|
+
evens, odds = stream(1..10).
|
|
159
|
+
tee(
|
|
160
|
+
filter_pipe{ it.even? }.pipe{ [:even, it] },
|
|
161
|
+
filter_pipe{ it.odd? }.pipe{ [:odd, it] },
|
|
162
|
+
).
|
|
163
|
+
reduce([[], []]) do |(evens, odds), (tag, n)|
|
|
164
|
+
tag == :even ? [evens << n, odds] : [evens, odds << n]
|
|
165
|
+
end
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
### Terminal operations
|
|
169
|
+
|
|
170
|
+
Terminal operations run in the caller Ractor (no Ractor is created for
|
|
171
|
+
them) and start the pipeline. Their blocks are ordinary blocks — no
|
|
172
|
+
isolation restrictions, so a mutable accumulator is fine.
|
|
173
|
+
|
|
174
|
+
```ruby
|
|
175
|
+
pl.reduce(initial){ |acc, elem| ... } # returns the final accumulator
|
|
176
|
+
pl.each{ |elem| ... } # yields each element, returns self
|
|
177
|
+
pl.to_a # collects into an Array
|
|
178
|
+
pl.count # number of output elements
|
|
179
|
+
pl.first # first element (stops the pipeline)
|
|
180
|
+
pl.first(n) # first n elements as an Array
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
`first` cancels the rest of the stream: it stops the feeder, closes its
|
|
184
|
+
output port, and broadcasts a cancel message, so workers drop their
|
|
185
|
+
backlog and terminate immediately (in-flight sends to closed ports raise
|
|
186
|
+
`Ractor::ClosedError` and cascade the shutdown upstream, like SIGPIPE in a
|
|
187
|
+
shell pipeline). Safe to use with an infinite `stream(1..)`.
|
|
188
|
+
|
|
189
|
+
### Errors
|
|
190
|
+
|
|
191
|
+
* An exception raised in a stage block cancels the pipeline and is
|
|
192
|
+
re-raised by the terminal operation in the caller:
|
|
193
|
+
|
|
194
|
+
```ruby
|
|
195
|
+
stream(1..10).pipe{ raise "boom" if it == 5; it }.to_a
|
|
196
|
+
#=> RuntimeError "boom" raised from .to_a
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
* Stage blocks are isolated with `Ractor.shareable_proc` at construction
|
|
200
|
+
time. Capturing a *shareable* outer value snapshots it; capturing a
|
|
201
|
+
non-shareable value raises `Ractor::IsolationError` immediately at
|
|
202
|
+
`.pipe` time (not at run time). `self` inside a stage block is `nil`,
|
|
203
|
+
so top-level helper methods are callable but instance methods are not.
|
|
204
|
+
|
|
205
|
+
```ruby
|
|
206
|
+
factor = 10
|
|
207
|
+
stream(1..3).pipe{ it * factor }.to_a #=> [10, 20, 30] (snapshot)
|
|
208
|
+
|
|
209
|
+
buf = String.new
|
|
210
|
+
stream(1..3).pipe{ buf << it.to_s } #=> Ractor::IsolationError at .pipe
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
* Everything a stage block returns must be sendable through a
|
|
214
|
+
`Ractor::Port`. A common gotcha: a Hash built with `Hash.new{ ... }`
|
|
215
|
+
holds a Proc as its default and cannot cross the boundary — build plain
|
|
216
|
+
hashes (`(h[k] ||= 0) += 1` style) in stage blocks.
|
|
217
|
+
|
|
218
|
+
## Samples
|
|
219
|
+
|
|
220
|
+
### wc: `cat README.md | grep Ruby | wc`
|
|
221
|
+
|
|
222
|
+
```ruby
|
|
223
|
+
lines, words, bytes =
|
|
224
|
+
stream(File.foreach("README.md")).
|
|
225
|
+
filter_pipe(lanes: 4){ it.include?("Ruby") }.
|
|
226
|
+
reduce([0, 0, 0]) do |(l, w, b), line|
|
|
227
|
+
[l + 1, w + line.scan(/\S+/).size, b + line.bytesize]
|
|
228
|
+
end
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
### Log crunching: parse JSONL, aggregate per path
|
|
232
|
+
|
|
233
|
+
One message carries a chunk of lines; each stage worker parses its chunk
|
|
234
|
+
and returns a small pre-aggregated hash, so the caller-side reduce merges
|
|
235
|
+
`#chunks` hashes instead of touching every record
|
|
236
|
+
(runnable version: [examples/logstats.rb](examples/logstats.rb)):
|
|
237
|
+
|
|
238
|
+
```ruby
|
|
239
|
+
stats = stream(lines.each_slice(1000)).
|
|
240
|
+
pipe(lanes: 8){ aggregate(it) }. # chunk -> {path => [req, err, ms]}
|
|
241
|
+
reduce({}){ |acc, st| merge(acc, st) }
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
### Unordered results: carry an index and reassemble
|
|
245
|
+
|
|
246
|
+
```ruby
|
|
247
|
+
picture = stream(0...height).
|
|
248
|
+
pipe(lanes: 8){ [it, render_row(it)] }.
|
|
249
|
+
reduce(Array.new(height)){ |acc, (y, row)| acc[y] = row; acc }
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
### Early termination of an infinite stream
|
|
253
|
+
|
|
254
|
+
```ruby
|
|
255
|
+
stream(1..).pipe{ it * it }.first(5) #=> [1, 4, 9, 16, 25]
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
### Fine-grained sources: use batch:
|
|
259
|
+
|
|
260
|
+
```ruby
|
|
261
|
+
stream(File.foreach(name), batch: 1000).
|
|
262
|
+
filter_pipe(lanes: 4){ it.include?("VALUE") }.
|
|
263
|
+
count
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
More runnable samples: [examples/demo.rb](examples/demo.rb) (feature tour),
|
|
267
|
+
[examples/perf.rb](examples/perf.rb) and
|
|
268
|
+
[examples/readme_bench.rb](examples/readme_bench.rb) (the measurements
|
|
269
|
+
below).
|
|
270
|
+
|
|
271
|
+
## Semantics notes
|
|
272
|
+
|
|
273
|
+
* <a name="ordering"></a>**Ordering**: a chain of `lanes: 1` stages
|
|
274
|
+
preserves input order, with or without `batch:`. Multi-lane stages are
|
|
275
|
+
unordered (elements overtake each other across lanes).
|
|
276
|
+
* **Boundaries copy**: non-shareable objects are deep-copied at each Ractor
|
|
277
|
+
boundary (`Ractor::Port#send` default); shareable objects are passed by
|
|
278
|
+
reference.
|
|
279
|
+
* **End of stream** propagates via an in-band EOS message: each worker
|
|
280
|
+
counts EOS from its upstream producers, drains its buffered output, and
|
|
281
|
+
broadcasts EOS downstream, so fan-in and fan-out shut down cleanly.
|
|
282
|
+
* **Cancellation** (`first`, exceptions) closes the terminal's port and
|
|
283
|
+
broadcasts cancel; workers drop their backlog and exit, and the closure
|
|
284
|
+
cascades upstream via `Ractor::ClosedError` (SIGPIPE-style).
|
|
285
|
+
* **Backpressure**: demand-driven boundaries are bounded (2 batches per
|
|
286
|
+
producer/consumer pair), and a multi-lane head stage throttles the
|
|
287
|
+
source itself. Push boundaries (into `lanes: 1` stages and the terminal)
|
|
288
|
+
are unbounded FIFO queues.
|
|
289
|
+
|
|
290
|
+
## Measured performance
|
|
291
|
+
|
|
292
|
+
Numbers from `examples/readme_bench.rb` (min of 5 runs) on Ruby 4.1.0dev
|
|
293
|
+
(2026-08-18 master, 98b3b8034d) on a 20-thread laptop (Core i7-1370P,
|
|
294
|
+
6P+8E cores, WSL2). Absolute numbers vary a lot with machine, clocks, and
|
|
295
|
+
Ruby version — treat them as shape, not truth. Ruby 4.0.2 behaves
|
|
296
|
+
similarly except that multi-Ractor CPU throughput at high lane counts is
|
|
297
|
+
~25% lower.
|
|
298
|
+
|
|
299
|
+
**Lanes sweep** — speedup over serial for `pipe(lanes: n)`:
|
|
300
|
+
|
|
301
|
+
| workload (serial time) | lanes 2 | lanes 4 | lanes 8 | lanes 16 |
|
|
302
|
+
|---|---|---|---|---|
|
|
303
|
+
| fib(28) x 32, uniform CPU (0.52s) | x1.9 | x3.3 | x2.6 | x5.5 |
|
|
304
|
+
| skewed load, 1 heavy + 31 light (0.85s) | x1.8 | x3.2 | x2.8 | x2.4 |
|
|
305
|
+
| mandelbrot, 48 uneven rows (0.32s) | x2.4 | x4.5 | x5.3 | x6.9 |
|
|
306
|
+
| JSONL aggregation, 400k lines (0.25s) | x1.6 | x2.8 | x2.4 | x2.0 |
|
|
307
|
+
|
|
308
|
+
Notes:
|
|
309
|
+
|
|
310
|
+
* **Skewed load** is bounded by the heavy element itself (optimal makespan
|
|
311
|
+
~x2.8 here); demand-driven distribution saturates that bound from
|
|
312
|
+
`lanes: 4` on. Blind round-robin caps at ~x2.4 because elements queued
|
|
313
|
+
behind the heavy one cannot move to an idle worker.
|
|
314
|
+
* **JSONL parsing is allocation-bound**, and allocation throughput scales
|
|
315
|
+
worse across Ractors than pure computation, so extra lanes stop helping
|
|
316
|
+
early. Pre-aggregate in the workers and keep boundary objects small.
|
|
317
|
+
* The recurring dip at `lanes: 8` is a machine artifact of this hybrid
|
|
318
|
+
P/E-core laptop under WSL2 (eight busy workers get placed badly), not a
|
|
319
|
+
property of the lane count. Expect ±20% run-to-run variance from clock
|
|
320
|
+
scaling in every cell.
|
|
321
|
+
|
|
322
|
+
**Message granularity** — trivial filter over 100k short lines:
|
|
323
|
+
|
|
324
|
+
| serial | 1 line = 1 message | `batch: 1000` |
|
|
325
|
+
|---|---|---|
|
|
326
|
+
| 0.011s | 1.56s | 0.021s |
|
|
327
|
+
|
|
328
|
+
Per-element messages cost ~1µs each (sync + copy + envelope); batching
|
|
329
|
+
amortizes that ~75x. Rule of thumb: aim for >= 1ms of work per message,
|
|
330
|
+
via `batch:` or by chunking the source.
|
|
331
|
+
|
|
332
|
+
**Source throttling** — infinite source, `pipe(lanes: 2){ it }.first(3)`:
|
|
333
|
+
the source's `each` is invoked a few dozen times at most (5–40 across
|
|
334
|
+
runs). A push-fed pipeline reads hundreds of thousands of elements before
|
|
335
|
+
the cancellation lands.
|
|
336
|
+
|
|
337
|
+
## vs the parallel gem
|
|
338
|
+
|
|
339
|
+
The [parallel](https://github.com/grosser/parallel) gem is a data-parallel
|
|
340
|
+
`map` over a ready-made collection using forked processes (or threads);
|
|
341
|
+
Ractor::Pipeline is an in-process streaming topology. On the overlapping
|
|
342
|
+
map-shaped workloads (`examples/vs_parallel.rb`, same machine/Ruby as
|
|
343
|
+
above, parallel 2.1.0):
|
|
344
|
+
|
|
345
|
+
| workload | Ractor::Pipeline | Parallel processes | Parallel threads |
|
|
346
|
+
|---|---|---|---|
|
|
347
|
+
| 32 x fib(28), 16-way | **x6.6** | x4.6 | x1.0 (GVL) |
|
|
348
|
+
| skewed load, 4-way | x3.3 | x3.4 | — |
|
|
349
|
+
| JSONL aggregation, 8-way | **x2.5** | x1.7 | x0.8 |
|
|
350
|
+
| 10k trivial jobs, 4-way | 0.002s | 0.510s | — |
|
|
351
|
+
|
|
352
|
+
* Threads cannot help CPU-bound work under the GVL; processes and Ractors
|
|
353
|
+
both can.
|
|
354
|
+
* Both distribute work by demand, so skewed load balances equally well.
|
|
355
|
+
* Ractor messages are in-process copies, cheaper than fork + Marshal over
|
|
356
|
+
pipes: the gap widens when jobs carry data (JSONL: x2.5 vs x1.7) and
|
|
357
|
+
becomes decisive for small jobs (250x on trivial jobs, thanks to
|
|
358
|
+
`batch:` and reusable workers).
|
|
359
|
+
* What Parallel cannot express at all: multi-stage pipelines (stages
|
|
360
|
+
running concurrently), infinite/throttled sources, and early
|
|
361
|
+
cancellation of a running stream. What Parallel gives you instead:
|
|
362
|
+
full process isolation and compatibility with any Ruby object without
|
|
363
|
+
shareability rules.
|
|
364
|
+
|
|
365
|
+
## License
|
|
366
|
+
|
|
367
|
+
MIT. See [LICENSE.txt](LICENSE.txt).
|
data/Rakefile
ADDED
data/examples/demo.rb
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Feature tour of Ractor::Pipeline. Self-contained: file-based samples read
|
|
4
|
+
# this file itself.
|
|
5
|
+
#
|
|
6
|
+
# ruby -I lib examples/demo.rb
|
|
7
|
+
|
|
8
|
+
Warning[:experimental] = false # suppress "Ractor API is experimental"
|
|
9
|
+
|
|
10
|
+
require "ractor/pipeline"
|
|
11
|
+
|
|
12
|
+
include Ractor::Pipeline
|
|
13
|
+
|
|
14
|
+
def section(title)
|
|
15
|
+
puts
|
|
16
|
+
puts "== #{title}"
|
|
17
|
+
yield
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
section "basic: pipe + reduce" do
|
|
21
|
+
sum = stream([1, 2, 3]).
|
|
22
|
+
pipe{ it * 2 }.
|
|
23
|
+
reduce(0){ |acc, n| acc + n }
|
|
24
|
+
p sum #=> 12
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
section "filter_pipe + pipe + to_a (lanes: 1 keeps order)" do
|
|
28
|
+
result = stream(1..10).
|
|
29
|
+
filter_pipe{ it.even? }.
|
|
30
|
+
pipe{ it * 10 }.
|
|
31
|
+
to_a
|
|
32
|
+
p result #=> [20, 40, 60, 80, 100]
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
section "wc: cat #{File.basename(__FILE__)} | grep Ractor | wc" do
|
|
36
|
+
lines, words, bytes =
|
|
37
|
+
stream(File.foreach(__FILE__)).
|
|
38
|
+
filter_pipe(lanes: 4){ it.include?("Ractor") }.
|
|
39
|
+
reduce([0, 0, 0]) do |(l, w, b), line|
|
|
40
|
+
[l + 1, w + line.scan(/\S+/).size, b + line.bytesize]
|
|
41
|
+
end
|
|
42
|
+
puts "lines: #{lines}, words: #{words}, bytes: #{bytes}"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
section "pipeline parallelism: two stages overlap" do
|
|
46
|
+
n = 6
|
|
47
|
+
wait = 0.05
|
|
48
|
+
|
|
49
|
+
t = Time.now
|
|
50
|
+
stream(1..n).
|
|
51
|
+
pipe{ sleep(0.05); it }. # stage 1
|
|
52
|
+
pipe{ sleep(0.05); it }. # stage 2 works while stage 1 handles the next element
|
|
53
|
+
each{}
|
|
54
|
+
pipeline_time = Time.now - t
|
|
55
|
+
|
|
56
|
+
printf "naive: %.3fs (= n * 2 stages * wait)\n", n * 2 * wait
|
|
57
|
+
printf "pipeline: %.3fs\n", pipeline_time
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
section "tee: broadcast to branches, merged output (unordered)" do
|
|
61
|
+
result = stream(1..5).
|
|
62
|
+
tee(
|
|
63
|
+
pipe{ [:double, it * 2] },
|
|
64
|
+
pipe{ [:square, it * it] },
|
|
65
|
+
).
|
|
66
|
+
to_a
|
|
67
|
+
p result.sort_by{ |tag, v| [tag.to_s, v] }
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
section "tee with filter branches" do
|
|
71
|
+
evens, odds = stream(1..10).
|
|
72
|
+
tee(
|
|
73
|
+
filter_pipe{ it.even? }.pipe{ [:even, it] },
|
|
74
|
+
filter_pipe{ it.odd? }.pipe{ [:odd, it] },
|
|
75
|
+
).
|
|
76
|
+
reduce([[], []]) do |(evens, odds), (tag, n)|
|
|
77
|
+
tag == :even ? [evens << n, odds] : [evens, odds << n]
|
|
78
|
+
end
|
|
79
|
+
p evens: evens.sort, odds: odds.sort
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
section "flat_pipe: 1 input -> N outputs" do
|
|
83
|
+
count = stream([__FILE__] * 3). # the same file, three times
|
|
84
|
+
flat_pipe{ File.foreach(it) }. # 1 file -> N lines
|
|
85
|
+
filter_pipe(lanes: 2){ it.include?("section") }.
|
|
86
|
+
count
|
|
87
|
+
puts "lines containing 'section' (x3): #{count}"
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
section "batch: transparent batching (blocks still see single elements)" do
|
|
91
|
+
result = stream(1..10, batch: 3).
|
|
92
|
+
filter_pipe{ it.even? }.
|
|
93
|
+
pipe{ it * 10 }.
|
|
94
|
+
to_a
|
|
95
|
+
p result #=> [20, 40, 60, 80, 100] (batch 越しでも lanes: 1 は順序保持)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
section "throttling: a multi-lane head reads the source on demand" do
|
|
99
|
+
reads = 0
|
|
100
|
+
counting = Enumerator.new do |y|
|
|
101
|
+
loop { y << (reads += 1) }
|
|
102
|
+
end
|
|
103
|
+
stream(counting).pipe(lanes: 2){ it }.first(3)
|
|
104
|
+
puts "source reads: #{reads} (push だと数十万読む)"
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
section "first: early termination of an infinite stream" do
|
|
108
|
+
result = stream(1..).
|
|
109
|
+
pipe{ it * it }.
|
|
110
|
+
first(5)
|
|
111
|
+
p result #=> [1, 4, 9, 16, 25]
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
section "exception: raised in a stage, re-raised at the terminal" do
|
|
115
|
+
begin
|
|
116
|
+
stream(1..10).
|
|
117
|
+
pipe{ raise "boom at #{it}" if it == 5; it }.
|
|
118
|
+
to_a
|
|
119
|
+
rescue RuntimeError => e
|
|
120
|
+
puts "caught: #{e.message} (#{e.class})"
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
section "block isolation: outer variables are snapshotted at stage construction" do
|
|
125
|
+
factor = 10
|
|
126
|
+
p stream(1..3).pipe{ it * factor }.to_a #=> [10, 20, 30]
|
|
127
|
+
|
|
128
|
+
# Referencing a non-shareable value fails early, at .pipe time.
|
|
129
|
+
buf = String.new("mutable")
|
|
130
|
+
begin
|
|
131
|
+
stream(1..3).pipe{ buf + it.to_s }
|
|
132
|
+
rescue Ractor::IsolationError => e
|
|
133
|
+
puts "caught: #{e.message}"
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
puts
|
|
138
|
+
puts "done."
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# A realistic-ish benchmark: aggregate statistics from JSONL access logs.
|
|
4
|
+
#
|
|
5
|
+
# ruby -I lib examples/logstats.rb
|
|
6
|
+
#
|
|
7
|
+
# Each line is a JSON object like
|
|
8
|
+
# {"path":"/api/users","status":200,"ms":12.3}
|
|
9
|
+
# and the task is: parse every line, count requests and errors per path,
|
|
10
|
+
# and sum latencies (i.e. the shape of a typical log-crunching script).
|
|
11
|
+
#
|
|
12
|
+
# The pipeline version chunks lines (1000 lines = 1 message), parses and
|
|
13
|
+
# pre-aggregates each chunk in parallel stage workers, and merges the
|
|
14
|
+
# small per-chunk hashes in the caller.
|
|
15
|
+
|
|
16
|
+
Warning[:experimental] = false # suppress "Ractor API is experimental"
|
|
17
|
+
|
|
18
|
+
require "ractor/pipeline"
|
|
19
|
+
require "json"
|
|
20
|
+
|
|
21
|
+
include Ractor::Pipeline
|
|
22
|
+
|
|
23
|
+
N_LINES = 400_000
|
|
24
|
+
CHUNK = 1_000
|
|
25
|
+
LANES = 8
|
|
26
|
+
|
|
27
|
+
def make_log(n)
|
|
28
|
+
rng = Random.new(42)
|
|
29
|
+
paths = ["/", "/api/users", "/api/items", "/api/search", "/login", "/assets/app.js"]
|
|
30
|
+
Array.new(n) do
|
|
31
|
+
path = paths[rng.rand(paths.size)]
|
|
32
|
+
status = rng.rand(100) < 3 ? 500 : 200
|
|
33
|
+
ms = (rng.rand * 300).round(1)
|
|
34
|
+
%({"path":"#{path}","status":#{status},"ms":#{ms}})
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# chunk of lines -> small aggregate hash {path => [requests, errors, total_ms]}
|
|
39
|
+
# NOTE: no Hash.new{} here — a Hash with a default_proc cannot cross a
|
|
40
|
+
# Ractor boundary (Procs are not copyable).
|
|
41
|
+
def aggregate(lines)
|
|
42
|
+
stats = {}
|
|
43
|
+
lines.each do |line|
|
|
44
|
+
rec = JSON.parse(line)
|
|
45
|
+
s = (stats[rec["path"]] ||= [0, 0, 0.0])
|
|
46
|
+
s[0] += 1
|
|
47
|
+
s[1] += 1 if rec["status"] >= 500
|
|
48
|
+
s[2] += rec["ms"]
|
|
49
|
+
end
|
|
50
|
+
stats
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def merge(acc, stats)
|
|
54
|
+
stats.each do |path, (req, err, ms)|
|
|
55
|
+
a = (acc[path] ||= [0, 0, 0.0])
|
|
56
|
+
a[0] += req
|
|
57
|
+
a[1] += err
|
|
58
|
+
a[2] += ms
|
|
59
|
+
end
|
|
60
|
+
acc
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def report(label, stats, dt, base = nil)
|
|
64
|
+
total = stats.values.sum(&:first)
|
|
65
|
+
speedup = base ? " (x%.2f)" % (base / dt) : ""
|
|
66
|
+
printf "%-28s %8.3fs%s (%d reqs, %d errors)\n",
|
|
67
|
+
label, dt, speedup, total, stats.values.sum{ |_, e, _| e }
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
lines = make_log(N_LINES)
|
|
71
|
+
puts "#{N_LINES} JSONL lines, batch/chunk=#{CHUNK}, lanes=#{LANES}"
|
|
72
|
+
|
|
73
|
+
t = Time.now
|
|
74
|
+
serial = lines.each_slice(CHUNK).map{ aggregate(it) }.reduce({}){ |a, s| merge(a, s) }
|
|
75
|
+
serial_dt = Time.now - t
|
|
76
|
+
report "serial", serial, serial_dt
|
|
77
|
+
|
|
78
|
+
# Style 1: transparent batching. Blocks see single lines/records: parse in
|
|
79
|
+
# the stage workers and project each record down to a small tuple before
|
|
80
|
+
# it crosses the boundary (copying the whole parsed Hash would cost about
|
|
81
|
+
# as much as parsing it).
|
|
82
|
+
t = Time.now
|
|
83
|
+
per_record = stream(lines, batch: CHUNK).
|
|
84
|
+
pipe(lanes: LANES){ r = JSON.parse(it); [r["path"], r["status"], r["ms"]] }.
|
|
85
|
+
reduce({}) do |acc, (path, status, ms)|
|
|
86
|
+
s = (acc[path] ||= [0, 0, 0.0])
|
|
87
|
+
s[0] += 1
|
|
88
|
+
s[1] += 1 if status >= 500
|
|
89
|
+
s[2] += ms
|
|
90
|
+
acc
|
|
91
|
+
end
|
|
92
|
+
per_record_dt = Time.now - t
|
|
93
|
+
report "batch + per-record reduce", per_record, per_record_dt, serial_dt
|
|
94
|
+
|
|
95
|
+
# Style 2: chunk-aggregate. One message carries a chunk of lines and the
|
|
96
|
+
# stage returns a small pre-aggregated hash, so the reduce merges
|
|
97
|
+
# #chunks hashes instead of touching every record.
|
|
98
|
+
t = Time.now
|
|
99
|
+
parallel = stream(lines.each_slice(CHUNK)).
|
|
100
|
+
pipe(lanes: LANES){ aggregate(it) }.
|
|
101
|
+
reduce({}){ |acc, stats| merge(acc, stats) }
|
|
102
|
+
parallel_dt = Time.now - t
|
|
103
|
+
report "chunk-aggregate + merge", parallel, parallel_dt, serial_dt
|
|
104
|
+
|
|
105
|
+
# Float sums depend on addition order (multi-lane stages are unordered),
|
|
106
|
+
# so compare with rounded latency totals.
|
|
107
|
+
def canon(stats) = stats.transform_values{ |req, err, ms| [req, err, ms.round(3)] }.sort
|
|
108
|
+
raise "mismatch" unless canon(serial) == canon(parallel) && canon(serial) == canon(per_record)
|
|
109
|
+
|
|
110
|
+
puts
|
|
111
|
+
puts "per-path stats:"
|
|
112
|
+
serial.sort_by{ |_, (req, _, _)| -req }.each do |path, (req, err, ms)|
|
|
113
|
+
printf " %-16s %7d reqs %5d errors %8.1f ms avg\n", path, req, err, ms / req
|
|
114
|
+
end
|