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.
data/examples/perf.rb ADDED
@@ -0,0 +1,184 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Performance demos of Ractor::Pipeline. Self-contained (synthetic data).
4
+ #
5
+ # ruby -I lib examples/perf.rb
6
+ #
7
+ # Note: speedup ceilings depend heavily on the machine (P/E core mix,
8
+ # all-core clock drop, etc.), and allocation-heavy stage blocks scale worse
9
+ # than pure computation.
10
+
11
+ Warning[:experimental] = false # suppress "Ractor API is experimental"
12
+
13
+ require "ractor/pipeline"
14
+
15
+ include Ractor::Pipeline
16
+
17
+ def section(title)
18
+ puts
19
+ puts "== #{title}"
20
+ yield
21
+ end
22
+
23
+ def bench
24
+ t = Time.now
25
+ result = yield
26
+ [result, Time.now - t]
27
+ end
28
+
29
+ def report(label, dt, base = nil)
30
+ if base
31
+ printf " %-36s %8.3fs (x%.2f)\n", label, dt, base / dt
32
+ else
33
+ printf " %-36s %8.3fs\n", label, dt
34
+ end
35
+ end
36
+
37
+ def fib(n) = n < 2 ? n : fib(n - 1) + fib(n - 2)
38
+
39
+ section "CPU-bound scaling: 16 x fib(30), pipe(lanes: n)" do
40
+ items = 16
41
+
42
+ expected, base = bench{ (1..items).sum{ fib(30) } }
43
+ report "serial", base
44
+
45
+ [1, 2, 4, 8, 16].each do |n|
46
+ result, dt = bench do
47
+ stream(1..items).
48
+ pipe(lanes: n){ fib(30) }.
49
+ reduce(0){ |acc, v| acc + v }
50
+ end
51
+ raise "mismatch" unless result == expected
52
+ report "pipe(lanes: #{n})", dt, base
53
+ end
54
+ end
55
+
56
+ # Synthetic corpus for the text-processing demos.
57
+ WORDS = %w[Ractor pipeline port stream lane stage worker copy share value].freeze
58
+
59
+ def make_corpus(docs, lines_per_doc)
60
+ rng = Random.new(42)
61
+ Array.new(docs) do
62
+ Array.new(lines_per_doc) do
63
+ Array.new(8){ WORDS[rng.rand(WORDS.size)] }.join(" ")
64
+ end
65
+ end
66
+ end
67
+
68
+ def word_tally(lines)
69
+ tally = Hash.new(0)
70
+ lines.each do |line|
71
+ line.scan(/\w+/){ |w| tally[w] += 1 }
72
+ end
73
+ tally
74
+ end
75
+
76
+ section "map-reduce: word ranking, 1 document = 1 message" do
77
+ corpus = make_corpus(160, 2_000)
78
+
79
+ serial_top, base = bench do
80
+ corpus.map{ word_tally(it) }.
81
+ reduce(Hash.new(0)){ |acc, t| t.each{ |w, n| acc[w] += n }; acc }.
82
+ max_by(3){ |_, n| n }
83
+ end
84
+ report "serial (#{corpus.size} docs)", base
85
+
86
+ parallel_top, dt = bench do
87
+ stream(corpus).
88
+ pipe(lanes: 8){ word_tally(it) }.
89
+ reduce(Hash.new(0)){ |acc, t| t.each{ |w, n| acc[w] += n }; acc }.
90
+ max_by(3){ |_, n| n }
91
+ end
92
+ report "pipe(lanes: 8) + reduce", dt, base
93
+
94
+ raise "mismatch" unless serial_top == parallel_top
95
+ puts " top words: " + serial_top.map{ |w, n| "#{w}(#{n})" }.join(", ")
96
+ end
97
+
98
+ MW, MH, MITER = 78, 48, 3000
99
+
100
+ # Allocation-light row computation: allocation-heavy stage blocks scale
101
+ # worse across Ractors than pure computation.
102
+ def mandel_row(y)
103
+ ci = -1.0 + 2.0 * y / MH
104
+ row = String.new(capacity: MW)
105
+ x = 0
106
+ while x < MW
107
+ cr = -2.2 + 3.2 * x / MW
108
+ zr = zi = 0.0
109
+ n = 0
110
+ while n < MITER && zr * zr + zi * zi < 4.0
111
+ zr, zi = zr * zr - zi * zi + cr, 2 * zr * zi + ci
112
+ n += 1
113
+ end
114
+ row << (n == MITER ? "*" : " ")
115
+ x += 1
116
+ end
117
+ row
118
+ end
119
+
120
+ section "mandelbrot: rows in parallel, reassembled by row index" do
121
+ serial_pic, base = bench{ (0...MH).map{ mandel_row(it) } }
122
+ report "serial (#{MW}x#{MH}, iter=#{MITER})", base
123
+
124
+ # lanes: 8 is unordered, so carry the row index and reassemble.
125
+ parallel_pic, dt = bench do
126
+ stream(0...MH).
127
+ pipe(lanes: 8){ [it, mandel_row(it)] }.
128
+ reduce(Array.new(MH)){ |acc, (y, row)| acc[y] = row; acc }
129
+ end
130
+ report "pipe(lanes: 8)", dt, base
131
+
132
+ raise "mismatch" unless serial_pic == parallel_pic
133
+ puts parallel_pic.map{ |row| " |#{row}|" }
134
+ end
135
+
136
+ section "granularity: per-line vs per-chunk messages" do
137
+ # Fine-grained messages (1 line = 1 message) are dominated by
138
+ # copy/communication cost; chunking restores the speedup.
139
+ lines = make_corpus(1, 20_000).first
140
+
141
+ serial_count, base = bench{ lines.count{ it.include?("Ractor") } }
142
+ report "serial (#{lines.size} lines)", base
143
+
144
+ line_count, dt = bench do
145
+ stream(lines).
146
+ filter_pipe(lanes: 4){ it.include?("Ractor") }.
147
+ count
148
+ end
149
+ report "per-line filter_pipe(lanes: 4)", dt, base
150
+
151
+ chunk_count, dt = bench do
152
+ stream(lines.each_slice(1000)).
153
+ pipe(lanes: 4){ it.count{ |line| line.include?("Ractor") } }.
154
+ reduce(0){ |acc, n| acc + n }
155
+ end
156
+ report "1000-line chunks, pipe(lanes: 4)", dt, base
157
+
158
+ batch_count, dt = bench do
159
+ stream(lines, batch: 1000).
160
+ filter_pipe(lanes: 4){ it.include?("Ractor") }.
161
+ count
162
+ end
163
+ report "batch: 1000 (transparent)", dt, base
164
+
165
+ raise "mismatch" unless serial_count == line_count && serial_count == chunk_count && serial_count == batch_count
166
+ puts " matching lines: #{serial_count}"
167
+ end
168
+
169
+ def busy(n) = (i = 0; i += 1 while i < n; i)
170
+
171
+ section "load balancing: one heavy element among light ones (lanes: 4)" do
172
+ # Demand-driven distribution: no new work is assigned to the worker
173
+ # that is stuck on the heavy element.
174
+ jobs = [40_000_000] + [4_000_000] * 31
175
+
176
+ _, base = bench{ jobs.each{ busy(it) } }
177
+ report "serial", base
178
+
179
+ _, dt = bench{ stream(jobs).pipe(lanes: 4){ busy(it) }.each{} }
180
+ report "pipe(lanes: 4)", dt, base
181
+ end
182
+
183
+ puts
184
+ puts "done."
@@ -0,0 +1,129 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Collects the numbers quoted in README (min of 3 runs each) and prints
4
+ # them as a markdown table.
5
+ #
6
+ # ruby -I lib examples/readme_bench.rb
7
+
8
+ Warning[:experimental] = false
9
+
10
+ require "ractor/pipeline"
11
+ require "json"
12
+
13
+ include Ractor::Pipeline
14
+
15
+ LANES = [2, 4, 8, 16].freeze
16
+
17
+ def fib(n) = n < 2 ? n : fib(n - 1) + fib(n - 2)
18
+ def busy(n) = (i = 0; i += 1 while i < n; i)
19
+
20
+ def best(times = 5)
21
+ times.times.map { t = Time.now; yield; Time.now - t }.min
22
+ end
23
+
24
+ MW, MH, MITER = 78, 48, 3000
25
+
26
+ def mandel_row(y)
27
+ ci = -1.0 + 2.0 * y / MH
28
+ row = String.new(capacity: MW)
29
+ x = 0
30
+ while x < MW
31
+ cr = -2.2 + 3.2 * x / MW
32
+ zr = zi = 0.0
33
+ n = 0
34
+ while n < MITER && zr * zr + zi * zi < 4.0
35
+ zr, zi = zr * zr - zi * zi + cr, 2 * zr * zi + ci
36
+ n += 1
37
+ end
38
+ row << (n == MITER ? "*" : " ")
39
+ x += 1
40
+ end
41
+ row
42
+ end
43
+
44
+ def agg(lines)
45
+ st = {}
46
+ lines.each do |l|
47
+ r = JSON.parse(l)
48
+ s = (st[r["path"]] ||= [0, 0, 0.0])
49
+ s[0] += 1
50
+ s[1] += 1 if r["status"] >= 500
51
+ s[2] += r["ms"]
52
+ end
53
+ st
54
+ end
55
+
56
+ puts RUBY_DESCRIPTION
57
+
58
+ rows = []
59
+
60
+ # 1. CPU-bound: 32 x fib(28)
61
+ items = 32
62
+ expected = (1..items).sum{ fib(28) }
63
+ base = best{ raise unless (1..items).sum{ fib(28) } == expected }
64
+ speedups = LANES.map do |n|
65
+ dt = best{ raise unless stream(1..items).pipe(lanes: n){ fib(28) }.reduce(0){ |a, v| a + v } == expected }
66
+ base / dt
67
+ end
68
+ rows << ["fib(28) x 32 (uniform)", base, speedups]
69
+
70
+ # 2. skewed load: 1 heavy + 31 light
71
+ jobs = [40_000_000] + [4_000_000] * 31
72
+ base = best{ jobs.each{ busy(it) } }
73
+ speedups = LANES.map do |n|
74
+ dt = best{ stream(jobs).pipe(lanes: n){ busy(it) }.each{} }
75
+ base / dt
76
+ end
77
+ rows << ["skewed load (1 heavy + 31 light)", base, speedups]
78
+
79
+ # 3. mandelbrot rows (uneven cost, ordered reassembly)
80
+ base = best{ (0...MH).map{ mandel_row(it) } }
81
+ speedups = LANES.map do |n|
82
+ dt = best{ stream(0...MH).pipe(lanes: n){ [it, mandel_row(it)] }.reduce(Array.new(MH)){ |a, (y, r)| a[y] = r; a } }
83
+ base / dt
84
+ end
85
+ rows << ["mandelbrot (48 rows, uneven)", base, speedups]
86
+
87
+ # 4. JSONL aggregation (allocation-bound), chunk-aggregate style
88
+ rng = Random.new(42)
89
+ paths = ["/", "/api/users", "/api/items", "/api/search", "/login", "/assets/app.js"]
90
+ lines = Array.new(400_000) do
91
+ %({"path":"#{paths[rng.rand(paths.size)]}","status":#{rng.rand(100) < 3 ? 500 : 200},"ms":#{(rng.rand * 300).round(1)}})
92
+ end
93
+ chunks = lines.each_slice(1000).to_a
94
+ base = best{ chunks.each{ agg(it) } }
95
+ speedups = LANES.map do |n|
96
+ dt = best do
97
+ stream(chunks).
98
+ pipe(lanes: n){ agg(it) }.
99
+ reduce({}){ |a, st| st.each{ |k, (q, e, m)| x = (a[k] ||= [0, 0, 0.0]); x[0] += q; x[1] += e; x[2] += m }; a }
100
+ end
101
+ base / dt
102
+ end
103
+ rows << ["JSONL aggregation (400k lines, alloc-bound)", base, speedups]
104
+
105
+ puts
106
+ puts "| workload (serial time) | " + LANES.map{ "lanes #{it}" }.join(" | ") + " |"
107
+ puts "|---|" + LANES.map{ "---" }.join("|") + "|"
108
+ rows.each do |name, base, speedups|
109
+ puts "| #{name} (#{"%.2fs" % base}) | " + speedups.map{ "x%.2f" % it }.join(" | ") + " |"
110
+ end
111
+
112
+ # 5. granularity (per-line vs batch)
113
+ gl = lines.first(100_000)
114
+ base = best{ gl.count{ it.include?(%("status":500)) } }
115
+ per_line = best{ stream(gl).filter_pipe(lanes: 4){ it.include?(%("status":500)) }.count }
116
+ batched = best{ stream(gl, batch: 1000).filter_pipe(lanes: 4){ it.include?(%("status":500)) }.count }
117
+ puts
118
+ printf "granularity: serial %.4fs / per-line %.3fs / batch1000 %.4fs\n", base, per_line, batched
119
+
120
+ # 6. throttling
121
+ class CountingSource
122
+ attr_reader :n
123
+
124
+ def initialize = @n = 0
125
+ def each = loop{ yield (@n += 1) }
126
+ end
127
+ cs = CountingSource.new
128
+ stream(cs).pipe(lanes: 2){ it }.first(3)
129
+ printf "throttle: source reads = %d (first(3), infinite source)\n", cs.n
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Compare Ractor::Pipeline with the parallel gem (process/thread based)
4
+ # on the map-shaped workloads from README.
5
+ #
6
+ # gem install parallel
7
+ # ruby -I lib examples/vs_parallel.rb
8
+ #
9
+ # Note the models differ: Parallel.map is a data-parallel map over a
10
+ # ready-made collection (fork/IPC per job set); Ractor::Pipeline is an
11
+ # in-process streaming topology. This compares only the overlap.
12
+
13
+ Warning[:experimental] = false
14
+
15
+ require "ractor/pipeline"
16
+ require "json"
17
+ begin
18
+ require "parallel"
19
+ rescue LoadError
20
+ abort "parallel gem not installed (gem install parallel)"
21
+ end
22
+
23
+ include Ractor::Pipeline
24
+
25
+ def fib(n) = n < 2 ? n : fib(n - 1) + fib(n - 2)
26
+ def busy(n) = (i = 0; i += 1 while i < n; i)
27
+
28
+ def best(times = 3)
29
+ times.times.map { t = Time.now; yield; Time.now - t }.min
30
+ end
31
+
32
+ def row(label, base, dt)
33
+ printf " %-32s %8.3fs (x%.2f)\n", label, dt, base / dt
34
+ end
35
+
36
+ puts RUBY_DESCRIPTION
37
+ puts "parallel #{Parallel::VERSION}"
38
+
39
+ section = ->(title){ puts; puts "== #{title}" }
40
+
41
+ # -- 1. uniform CPU: 32 x fib(28) ----------------------------------------
42
+ section.call "uniform CPU: 32 x fib(28)"
43
+ items = (1..32).to_a
44
+ expected = items.sum{ fib(28) }
45
+ base = best{ raise unless items.sum{ fib(28) } == expected }
46
+ printf " %-32s %8.3fs\n", "serial", base
47
+ [8, 16].each do |n|
48
+ dt = best{ raise unless stream(items).pipe(lanes: n){ fib(28) }.reduce(0){ |a, v| a + v } == expected }
49
+ row "Ractor::Pipeline lanes: #{n}", base, dt
50
+ end
51
+ [8, 16].each do |n|
52
+ dt = best{ raise unless Parallel.map(items, in_processes: n){ fib(28) }.sum == expected }
53
+ row "Parallel in_processes: #{n}", base, dt
54
+ end
55
+ dt = best{ raise unless Parallel.map(items, in_threads: 8){ fib(28) }.sum == expected }
56
+ row "Parallel in_threads: 8 (GVL)", base, dt
57
+
58
+ # -- 2. skewed load: 1 heavy + 31 light ----------------------------------
59
+ section.call "skewed load: 1 heavy (~0.3s) + 31 light (~30ms)"
60
+ jobs = [40_000_000] + [4_000_000] * 31
61
+ base = best{ jobs.each{ busy(it) } }
62
+ printf " %-32s %8.3fs\n", "serial", base
63
+ dt = best{ stream(jobs).pipe(lanes: 4){ busy(it) }.each{} }
64
+ row "Ractor::Pipeline lanes: 4", base, dt
65
+ dt = best{ Parallel.each(jobs, in_processes: 4){ busy(it) } }
66
+ row "Parallel in_processes: 4", base, dt
67
+
68
+ # -- 3. JSONL aggregation (allocation-bound) ------------------------------
69
+ section.call "JSONL aggregation: 400k lines, 1000-line chunks"
70
+ rng = Random.new(42)
71
+ paths = ["/", "/api/users", "/api/items", "/api/search", "/login", "/assets/app.js"]
72
+ lines = Array.new(400_000) do
73
+ %({"path":"#{paths[rng.rand(paths.size)]}","status":#{rng.rand(100) < 3 ? 500 : 200},"ms":#{(rng.rand * 300).round(1)}})
74
+ end
75
+ chunks = lines.each_slice(1000).to_a
76
+
77
+ def agg(lines)
78
+ st = {}
79
+ lines.each do |l|
80
+ r = JSON.parse(l)
81
+ s = (st[r["path"]] ||= [0, 0, 0.0])
82
+ s[0] += 1
83
+ s[1] += 1 if r["status"] >= 500
84
+ s[2] += r["ms"]
85
+ end
86
+ st
87
+ end
88
+
89
+ def merge_all(stats_list)
90
+ stats_list.each_with_object({}) do |st, a|
91
+ st.each{ |k, (q, e, m)| x = (a[k] ||= [0, 0, 0.0]); x[0] += q; x[1] += e; x[2] += m }
92
+ end
93
+ end
94
+
95
+ base = best{ merge_all(chunks.map{ agg(it) }) }
96
+ printf " %-32s %8.3fs\n", "serial", base
97
+ dt = best do
98
+ stream(chunks).
99
+ pipe(lanes: 8){ agg(it) }.
100
+ reduce({}){ |a, st| st.each{ |k, (q, e, m)| x = (a[k] ||= [0, 0, 0.0]); x[0] += q; x[1] += e; x[2] += m }; a }
101
+ end
102
+ row "Ractor::Pipeline lanes: 8", base, dt
103
+ dt = best{ merge_all(Parallel.map(chunks, in_processes: 8){ agg(it) }) }
104
+ row "Parallel in_processes: 8", base, dt
105
+ dt = best{ merge_all(Parallel.map(chunks, in_threads: 8){ agg(it) }) }
106
+ row "Parallel in_threads: 8 (GVL)", base, dt
107
+
108
+ # -- 4. per-job overhead: tiny jobs ---------------------------------------
109
+ section.call "per-job overhead: 10k trivial jobs (it + 1)"
110
+ tiny = (1..10_000).to_a
111
+ base = best{ tiny.sum{ it + 1 } }
112
+ printf " %-32s %8.3fs\n", "serial", base
113
+ dt = best{ raise unless stream(tiny, batch: 500).pipe(lanes: 4){ it + 1 }.count == 10_000 }
114
+ row "Ractor::Pipeline lanes:4 batch:500", base, dt
115
+ dt = best{ raise unless Parallel.map(tiny, in_processes: 4){ it + 1 }.size == 10_000 }
116
+ row "Parallel in_processes: 4", base, dt
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Ractor
4
+ module Pipeline
5
+ VERSION = "0.2.0"
6
+ end
7
+ end