farm.rb 0.0.0 → 0.0.1
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 +4 -4
- data/CHANGELOG +6 -0
- data/ROADMAP.md +8 -16
- data/lib/Farm/RactorExecutor.rb +23 -14
- data/lib/Farm/VERSION.rb +1 -1
- data/test/Farm/RactorExecutor_test.rb +12 -0
- data/test/farm_test.rb +21 -0
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: '094ced22f83044093d73911b2e7732b2fc459135baab73f07250746a6a9b348b'
|
|
4
|
+
data.tar.gz: da8a822c3a21ad9c8068fa52ea9fb005252ef3e3678872d181b43fa0d43a90cc
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 13ff9638c92f96168afed6c43d5533f3479f45a2239557a7895cd65e9ca89894d220caa1f8b87995ed9c11f6ddcda9a5d618668b652303434d8551d14572a256
|
|
7
|
+
data.tar.gz: 7e6ab7b1dccd567f72b936e871ddba3c1545bf3a39004c654eb3b64d9a71f84cfd1adf15493be8df7632f14de09b57535b57e5df06d82c94e341557a4f7c58ab
|
data/CHANGELOG
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
# CHANGELOG
|
|
2
2
|
|
|
3
|
+
## 20261005
|
|
4
|
+
|
|
5
|
+
0.0.1: send-time TypeError folded into Farm::NotParallelisable
|
|
6
|
+
|
|
7
|
+
1. Farm::RactorExecutor: a job which cannot be copied to a worker at all — a Proc in its graph — failed at send time as TypeError, escaping the rescue Farm falls back upon though the failure was the machinery's rather than the work's. Workers are now collected as they spawn, and a TypeError at spawn or send winds the survivors down and raises NotParallelisable, the work running serially as promised and the caller none the wiser.
|
|
8
|
+
|
|
3
9
|
## 20261004
|
|
4
10
|
|
|
5
11
|
0.0.0: + Farm, + Farm.each, map, Farm::Probe, Farm::RactorExecutor, Farm::SerialExecutor
|
data/ROADMAP.md
CHANGED
|
@@ -1,35 +1,27 @@
|
|
|
1
|
-
#
|
|
1
|
+
# farm/ROADMAP.md
|
|
2
2
|
|
|
3
|
-
Date:
|
|
3
|
+
Date: 20261005
|
|
4
4
|
|
|
5
|
-
The
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
## Design decisions
|
|
9
|
-
|
|
10
|
-
Recorded as 0.0.0 was cut. The version entries beneath may change; these hold.
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
### The work travels as a name, or it does not travel
|
|
5
|
+
## The work travels as a name, or it does not travel
|
|
14
6
|
|
|
15
7
|
A Proc cannot cross a Ractor boundary, and nothing launders one: `define_method` from a Proc keeps the unshareable body it was defined from — isolation is decided by the Proc, not by the method built around it — and a Method object cannot cross whatever it was built from. A Proc built at the top level cannot even be isolated in place, `self` there being `main`. What runs is a module reached by name.
|
|
16
8
|
|
|
17
9
|
Farm therefore asks for a callable and a method upon it, runs a block serially and says so, and leaves hosting to the consumer: render the body as source into a module of one's own and it crosses as well as anything typed by hand. The one route which should not be taken is regenerating a Proc's source into a shared carrier — a captured local silently rebinds to a same-named method already collected there, the answer changes, and nothing complains. Work should be born hosted: source first, closure never. (Measured, surveys and tables in MEASUREMENTS.md.)
|
|
18
10
|
|
|
19
11
|
|
|
20
|
-
|
|
12
|
+
## Embeddable, in both directions
|
|
21
13
|
|
|
22
14
|
Farm is meant to sit underneath other libraries, and to mean nothing to their users. The whole contract is `map(enumerable, callable, method)`: work travels as a name. A consumer renders whatever it has — Namo's formulae, or anything else's named computation — into a module and a method upon it, and Farm cannot tell that module from one typed by hand. Hosting source-carried computation is the consumer's business: eval and its trust questions never enter Farm.
|
|
23
15
|
|
|
24
16
|
Invisibility runs both ways. The host's users never meet Farm — the consumer soft-requires the gem, and without it everything runs serially and answers identically. Farm never meets the host's domain — no rows, no formulae, nothing above `map`'s three arguments. Farm.rb stays swappable for anything answering the same three-argument shape, and nothing upstream of the consumer needs to change.
|
|
25
17
|
|
|
26
18
|
|
|
27
|
-
|
|
19
|
+
## Observational equivalence
|
|
28
20
|
|
|
29
21
|
Every executor and every fall-back answers the same values in the same order, and raises the same exceptions serial would raise. Speed is the only admissible tell. This is the acceptance test for everything below and everything after: a parallel run which differs from the serial run in anything but time is a bug, wherever the difference surfaced.
|
|
30
22
|
|
|
31
23
|
|
|
32
|
-
|
|
24
|
+
## Measured, never tabulated
|
|
33
25
|
|
|
34
26
|
Width, substrate, and whether a job is worth spreading are read off the machine and the work in front of it, never configured by the caller and never assumed from a table. Four rules of practice, each bought with a measurement which first came out wrong:
|
|
35
27
|
|
|
@@ -39,7 +31,7 @@ Width, substrate, and whether a job is worth spreading are read off the machine
|
|
|
39
31
|
- Every measured figure is a prior with a noise floor. Run-to-run spread for `fork` is around forty-five per cent, so a difference under about two-fold is no finding, and one run of anything will always name a winner. More than one conclusion here was first arrived at wrongly by an instrument returning a plausible figure for the wrong question; where a number underlies a decision it is re-measured rather than trusted.
|
|
40
32
|
|
|
41
33
|
|
|
42
|
-
##
|
|
34
|
+
## Embeddable
|
|
43
35
|
|
|
44
36
|
The version which makes sitting underneath possible — the fall-back closed over transport, the substrate set widened beyond ractors, and the first measurement feeding the deciding.
|
|
45
37
|
|
|
@@ -47,7 +39,7 @@ The version which makes sitting underneath possible — the fall-back closed ove
|
|
|
47
39
|
|
|
48
40
|
2. + Farm::ForkExecutor: a pool of forked processes, the one substrate which imposes no isolation rules at all — the child holds the whole process, so blocks run as written and code touching globals or class state just works. Its costs are measured and they decide when it is used rather than whether it exists: about a millisecond to start against a Ractor's 0.033; a cost which belongs to the calling process rather than the machine, rising with the resident set's high-water mark — twenty-fold at a gigabyte held — and never coming back down; results home through Marshal and a pipe at about 380MB/s, so a large enough answer argues against fork on its own. Its startup figure is estimated from `Probe.resident_memory` times a calibrated rate and corrected from the first real dispatch — never probed at startup, the probe costing more than it settles. Fork is the fall-back for computation, not the default substrate.
|
|
49
41
|
|
|
50
|
-
3. + ThreadExecutor: the fall-back for waiting work. Threads do not scale upon computation — the GVL is held throughout, 1.00x at every width — and scale nearly perfectly upon waiting, where a worker occupies no core. So the measured policy stands: try a Ractor first whatever the work, and fall back by class — threads for waiting, fork for computation. Nothing recommends `spawn` for Ruby work; exec'ing the interpreter afresh costs forty milliseconds a unit.
|
|
42
|
+
3. + Farm::ThreadExecutor: the fall-back for waiting work. Threads do not scale upon computation — the GVL is held throughout, 1.00x at every width — and scale nearly perfectly upon waiting, where a worker occupies no core. So the measured policy stands: try a Ractor first whatever the work, and fall back by class — threads for waiting, fork for computation. Nothing recommends `spawn` for Ruby work; exec'ing the interpreter afresh costs forty milliseconds a unit.
|
|
51
43
|
|
|
52
44
|
4. The measurement loop goes in. Three inputs decide: the job's total serial work, the per-unit duration, and the size of what comes back. Whether to parallelise is decided upon the job, not the unit — a pool pays setup per worker rather than per unit, so `N·D > W·S/(1−1/k)` per candidate substrate and width: a Ractor pool of eight wants about a third of a millisecond of work, of forty-eight about 1.6ms, fork about 12ms in a small process. Which class the work is: a two-width sweep watching whether wall time falls as workers rise — a few milliseconds, and no understanding of the work required. Whether the work will cross: speculate one unit inside a Ractor, one unit plus 0.03ms, whereupon isolation refusal routes to the class's fall-back and anything else is the work's own error and re-raises. Below the threshold for every substrate the job runs serially; units too small to clear it are batched until the batch does.
|
|
53
45
|
|
data/lib/Farm/RactorExecutor.rb
CHANGED
|
@@ -7,9 +7,11 @@ module Farm
|
|
|
7
7
|
# globals, shared mutable state — is discovered inside the worker, tagged,
|
|
8
8
|
# and answered with NotParallelisable so the caller can run it serially;
|
|
9
9
|
# the Ractor API does its own discovering at spawn and send time, which
|
|
10
|
-
# arrives here as Ractor::Error and is met the same way by Farm
|
|
11
|
-
#
|
|
12
|
-
#
|
|
10
|
+
# arrives here as Ractor::Error and is met the same way by Farm — as is
|
|
11
|
+
# TypeError, what a job which cannot be copied at all arrives as, the
|
|
12
|
+
# survivors wound down first. Workers rescue and tag rather than being
|
|
13
|
+
# allowed to die, a worker's death delivering nothing to the port and
|
|
14
|
+
# leaving its replacement to hang.
|
|
13
15
|
class RactorExecutor
|
|
14
16
|
class << self
|
|
15
17
|
def map(enumerable, callable, method = :call)
|
|
@@ -45,21 +47,28 @@ module Farm
|
|
|
45
47
|
|
|
46
48
|
def run
|
|
47
49
|
port = Ractor::Port.new
|
|
48
|
-
workers =
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
50
|
+
workers = []
|
|
51
|
+
begin
|
|
52
|
+
width.times do
|
|
53
|
+
workers << Ractor.new(port, @callable, @method) do |results, callable, method|
|
|
54
|
+
loop do
|
|
55
|
+
job = Ractor.receive
|
|
56
|
+
break if job.nil?
|
|
57
|
+
index, item = job
|
|
58
|
+
begin
|
|
59
|
+
results << [:ok, index, callable.public_send(method, item)]
|
|
60
|
+
rescue => error
|
|
61
|
+
results << [:error, index, error.class.name, error.message]
|
|
62
|
+
end
|
|
58
63
|
end
|
|
59
64
|
end
|
|
60
65
|
end
|
|
66
|
+
@jobs.each_with_index{|job, index| workers[index % width].send(job)}
|
|
67
|
+
rescue TypeError => error
|
|
68
|
+
workers.each{|worker| worker.send(nil)}
|
|
69
|
+
workers.each(&:join)
|
|
70
|
+
raise NotParallelisable, "TypeError feeding a Ractor worker: #{error.message}"
|
|
61
71
|
end
|
|
62
|
-
@jobs.each_with_index{|job, index| workers[index % width].send(job)}
|
|
63
72
|
workers.each{|worker| worker.send(nil)}
|
|
64
73
|
collected = Array.new(@jobs.length){port.receive}
|
|
65
74
|
workers.each(&:join)
|
data/lib/Farm/VERSION.rb
CHANGED
|
@@ -26,6 +26,10 @@ module FarmRactorExecutorTestBoom
|
|
|
26
26
|
end
|
|
27
27
|
end
|
|
28
28
|
|
|
29
|
+
# A job carrying a Proc cannot be copied to a worker at all: the copy fails
|
|
30
|
+
# at send time as TypeError, before any work is done.
|
|
31
|
+
FarmRactorExecutorTestProcPayload = Struct.new(:value, :fn)
|
|
32
|
+
|
|
29
33
|
describe Farm::RactorExecutor do
|
|
30
34
|
describe ".map" do
|
|
31
35
|
it "maps with a callable, #call being the default method" do
|
|
@@ -51,6 +55,14 @@ describe Farm::RactorExecutor do
|
|
|
51
55
|
}).must_raise(Farm::NotParallelisable)
|
|
52
56
|
_(error.message).must_match(/Ractor::IsolationError/)
|
|
53
57
|
end
|
|
58
|
+
|
|
59
|
+
it "answers NotParallelisable when a job cannot cross" do
|
|
60
|
+
payloads = [1, 2, 3].map{|value| FarmRactorExecutorTestProcPayload.new(value, proc{|item| item})}
|
|
61
|
+
error = _(proc{
|
|
62
|
+
Farm::RactorExecutor.map(payloads, FarmRactorExecutorTestDoubler)
|
|
63
|
+
}).must_raise(Farm::NotParallelisable)
|
|
64
|
+
_(error.message).must_match(/TypeError/)
|
|
65
|
+
end
|
|
54
66
|
end
|
|
55
67
|
|
|
56
68
|
describe ".each" do
|
data/test/farm_test.rb
CHANGED
|
@@ -22,6 +22,17 @@ module FarmAPITestGlobalToucher
|
|
|
22
22
|
end
|
|
23
23
|
end
|
|
24
24
|
|
|
25
|
+
# A job carrying a Proc cannot be copied to a worker at all: the copy fails
|
|
26
|
+
# at send time as TypeError, before any work is done, and Farm's answer to
|
|
27
|
+
# the caller ought to be the serial answers, the machinery kept out of it.
|
|
28
|
+
FarmAPITestProcPayload = Struct.new(:value, :fn)
|
|
29
|
+
|
|
30
|
+
module FarmAPITestProcPayloadDoubler
|
|
31
|
+
def self.call(item)
|
|
32
|
+
item.fn.call(item.value)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
25
36
|
describe Farm do
|
|
26
37
|
describe ".map" do
|
|
27
38
|
it "maps with a callable across the Ractor executor" do
|
|
@@ -44,6 +55,11 @@ describe Farm do
|
|
|
44
55
|
_(Farm.map([1, 2, 3], FarmAPITestGlobalToucher)).must_equal([2, 4, 6])
|
|
45
56
|
_($farm_api_test_counter).must_equal(3)
|
|
46
57
|
end
|
|
58
|
+
|
|
59
|
+
it "falls back to the serial executor when a job cannot cross" do
|
|
60
|
+
payloads = [1, 2, 3].map{|value| FarmAPITestProcPayload.new(value, proc{|item| item * 2})}
|
|
61
|
+
_(Farm.map(payloads, FarmAPITestProcPayloadDoubler)).must_equal([2, 4, 6])
|
|
62
|
+
end
|
|
47
63
|
end
|
|
48
64
|
|
|
49
65
|
describe ".each" do
|
|
@@ -63,5 +79,10 @@ describe Farm do
|
|
|
63
79
|
Farm.each([1, 2, 3], FarmAPITestGlobalToucher)
|
|
64
80
|
_($farm_api_test_counter).must_equal(3)
|
|
65
81
|
end
|
|
82
|
+
|
|
83
|
+
it "falls back to the serial executor when a job cannot cross" do
|
|
84
|
+
payloads = [1, 2, 3].map{|value| FarmAPITestProcPayload.new(value, proc{|item| item * 2})}
|
|
85
|
+
_(Farm.each(payloads, FarmAPITestProcPayloadDoubler)).must_be_same_as(payloads)
|
|
86
|
+
end
|
|
66
87
|
end
|
|
67
88
|
end
|