Skip to content

Commit e2ad7e7

Browse files
committed
CPU Multithreading - Single/Multithread support in Options
1 parent b3481fa commit e2ad7e7

17 files changed

Lines changed: 2772 additions & 563 deletions

docs/multicore.md

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
# Multi-core calculation support — dev handoff notes
2+
3+
## Changelog (PR summary)
4+
5+
**Adds multi-core support for the four "iterate candidates, rank outcomes" features, with a user toggle (default: multi-core) and automatic single-core fallback.**
6+
7+
Parallelized features:
8+
- Passive tree node power / heat map / "Show Power Report" (`CalcsTab:PowerBuilder`)
9+
- Compare tab power report (`CompareTab`)
10+
- Anoint notable stat-sorting (`NotableDBControl`)
11+
- Item DB stat-sorting (`ItemDBControl`)
12+
13+
### Architecture
14+
15+
Workers are one-shot isolated Lua VMs started via the engine's existing `LaunchSubScript` primitive (no engine changes). Each worker bootstraps a headless PoB (HeadlessWrapper pattern), reconstructs the build from an in-memory `SaveDB` XML snapshot, computes its batch using **the same per-candidate functions the single-core path runs**, and returns one JSON payload (dkjson). The main thread partitions candidates, aggregates progress via subList calls, validates results, and merges by stable IDs.
16+
17+
### New files
18+
- `src/Modules/ParallelRunner.lua` — worker pool / job manager: launch, cancel, progress aggregation, baseline validation, per-session sticky fallback, dev-mode job log (`src/parallel_log.txt`)
19+
- `src/CalcWorker.lua` — worker script (read via `io.open`, passed as scriptText like `UpdateCheck.lua`): hardened headless stubs, script-path-relative `io.open` resolution (worker threads don't inherit a guaranteed cwd), env probe mode
20+
- `src/Modules/PowerCalcTasks.lua` — shared per-candidate compute logic + JSON number sentinels + baseline extraction, loaded by both main thread and workers
21+
- `spec/System/TestParallelTasks_spec.lua` — equivalence tests: single-core vs worker entry points in-process, through a JSON round trip, for all four features; merge staleness tests
22+
23+
### Modified files
24+
- `src/Launch.lua``OnSubError` now notifies CUSTOM subscript callbacks (`(nil, errMsg)` — audited compatible with all existing users); nil-guards in `OnSubError`/`OnSubFinished` for aborted subscripts; dev-mode F7 = worker environment probe (report written to `src/probe_report.txt`)
25+
- `src/Classes/CalcsTab.lua``PowerBuilder(filter)` optional candidate filter (nil = byte-identical legacy behavior); `EnumeratePowerCandidates`/`BuildPowerBatches` (modKey-grouped, weight-balanced batches carrying authoritative `node.path`/`node.depends` id lists); dual-path `BuildPower` with duplicate-launch guard; `LaunchParallelPowerBuild`/`MergeParallelPower` (staleness via `outputRevision`)
26+
- `src/Classes/CompareTab.lua``ComparePowerBuilder` split into enumerate / `ComputeComparePowerCandidate` / format-row so both paths share candidate computation; dual-path report runner
27+
- `src/Classes/NotableDBControl.lua`, `src/Classes/ItemDBControl.lua``ListBuilder` split into filter / measure / finish; dual-path `Draw`; candidates keyed by node id / item name
28+
- `src/Modules/Main.lua``computationMode` ("MULTI"/"SINGLE", default MULTI) and `workerCount` (0 = auto) settings, persisted in `Settings.xml`; Options popup UI; loads `ParallelRunner`/`PowerCalcTasks` as globals
29+
- `src/Modules/Build.lua` — cancels outstanding worker jobs on shutdown
30+
- `src/Modules/DataLegionLookUpTableHelper.lua`**standalone bugfix**: `loadJewelFile` could cache empty jewel data to `<jewel>.bin` when `Inflate` is stubbed (headless/busted), silently corrupting timeless jewel stats for the full app afterwards. Now rejects empty data, reads an existing `.bin` directly when `NewFileSearch` is unavailable, and never writes the shared cache from headless contexts
31+
32+
### Correctness guarantees
33+
- Workers and single-core run identical code; results cross the thread boundary as JSON with `inf`/NaN sentinels
34+
- Every worker returns a baseline stat vector recomputed from its reconstructed build; any relative deviation > 1e-3 from the main thread's baseline fails the whole job → automatic single-core fallback (sticky for the session, cleared by toggling the option or a successful F7 probe)
35+
- Main thread sends each candidate's live `path`/`depends` node ids: a spec rebuilt from XML resolves equal-length shortest-path ties in different `pairs()` order, which would otherwise change `pathPower` vs what the UI displays
36+
- Results merge only if `build.outputRevision` is unchanged since the snapshot; any build change mid-job cancels/discards and re-runs
37+
- Worker errors surface once via `OnSubError` → fallback; workers never write shared caches
38+
- Headless/CI: `HeadlessWrapper.lua` stubs `LaunchSubScript` → everything transparently uses the single-core path; no platform checks anywhere (pure feature detection)
39+
40+
### Verified
41+
- `spec/System/TestParallelTasks_spec.lua`: 6/6 (all four features numerically identical through the worker entry points + JSON round trip)
42+
- Full existing spec suite: zero regressions vs pristine master baseline (same harness, failure sets identical)
43+
- Cross-process fresh-VM E2E: 781/781 nodes bit-identical on a 3.13 test build; pass on a live 3.28 build with a Brutal Restraint timeless jewel
44+
- Real engine: worker baseline probe exact match on all stats; heat map on a level-95 build computes 4,348 candidates in ~15 s (8 workers) vs ~48 s single-core
45+
- Engine facts established via probe: subscript VMs receive funcList injections (incl. `GetUserPath`) but not `NewFileSearch`/`Inflate`/`Deflate`; worker-thread cwd is launch-dependent (both handled in `CalcWorker.lua`)
46+
47+
### Performance (measured, 5950X SMT-off, level-95 build, full-depth heat map)
48+
- Single-core: ~48 s (one core busy)
49+
- Multi-core, 8 workers (auto default): ~15–27 s depending on system load
50+
- Fixed overhead per job: ~5–7 s worker bootstrap (one-shot VMs must load all game data + build) + ~1–2 s snapshot/merge — this is the Amdahl floor; raise `worker threads` to physical cores − 1 to approach it
51+
52+
---
53+
54+
## Future speed projects (ordered by expected impact)
55+
56+
1. **Persistent warm workers (engine change — removes the ~7 s floor).** `LaunchSubScript` VMs are one-shot; every job pays a full bootstrap. A persistent worker/channel primitive in SimpleGraphic (send job → receive result → VM stays alive) would drop heat-map latency to pure compute (~3–6 s at 15 workers). Largest win, requires C++ runtime work upstream.
57+
2. **Result caching across tree toggles.** Allocating then un-allocating a node currently recomputes everything twice. Caching the last few power-result sets keyed by a spec/state hash makes "undo a change" instant. Cheap, and directly targets the most common interaction.
58+
3. **Trim worker bootstrap.** Profile `timing.initMs` vs `bootstrapMs` (already reported by workers): skip unique/rare item DB loading outside itemdb mode, skip tree versions the build doesn't use, lazy-load legion/tattoo data unless present, and cache Data modules as precompiled bytecode (`string.dump`). Realistic target: 7 s → 3–4 s.
59+
4. **Wave scheduling for stragglers.** The job ends when the slowest worker finishes; per-candidate cost varies (masteries, long paths). Splitting into ~2× more batches than workers and launching a second wave as results return evens out the tail without engine changes.
60+
5. **Stat-scoped calculation (benefits single-core too).** Each candidate runs a full offence+defence pipeline even when the heat map only needs one stat (e.g. Life). A calculation mode that skips unneeded sections could cut per-candidate cost several-fold — invasive, touches `calcs.perform`.
61+
6. **Parallelize the Timeless Jewel search.** "Find Timeless Jewel" brute-forces thousands of seeds through the same kind of loop and is a notorious multi-minute wait — it fits the existing ParallelRunner/batch pattern well and may be the single most user-visible follow-up.
62+
7. **Progressive merge.** Color the tree per-worker as payloads arrive instead of waiting for the last one — no wall-time change, large perceived-latency change.
63+
8. **Auto worker count tuning.** Auto currently clamps at 8 for memory safety; scale the cap with detected physical cores and available RAM instead (e.g. `min(cores − 1, RAM_GB / 1.5, 16)`).

docs/rundown.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,8 @@
135135
Patch notes written in plain text.
136136
* **CONTRIBUTING.md**
137137
Contribution guides.
138+
* **CalcWorker.lua**
139+
Background calculation worker script, executed in isolated Lua VMs on worker threads via `LaunchSubScript` (see `Modules/ParallelRunner.lua`). Bootstraps a headless copy of the program (like `HeadlessWrapper.lua`), reconstructs the build from an XML snapshot, computes a batch of candidates via `Modules/PowerCalcTasks.lua`, and returns the results as JSON.
138140
* **GameVersions.lua**
139141
Contains global variables to identify and convert outdated builds. Also contains global table of passive skill tree versions used to upgrade to newer skill tree versions.
140142
* **HeadlessWrapper.lua**

runtime/Path of Building.exe

401 KB
Binary file not shown.
Lines changed: 228 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,228 @@
1+
-- Tests for the multi-core calculation support (Modules/ParallelRunner,
2+
-- Modules/PowerCalcTasks, CalcWorker.lua).
3+
--
4+
-- Real worker threads can't run headless (LaunchSubScript is stubbed), so
5+
-- these tests run the worker entry points (computeBatch) in-process on the
6+
-- same build, round-trip the payloads through JSON exactly as the thread
7+
-- boundary would, merge them, and require results identical to the
8+
-- single-core code path.
9+
local dkjson = require "dkjson"
10+
11+
local function jsonRoundTrip(payload)
12+
return dkjson.decode(dkjson.encode(payload))
13+
end
14+
15+
local function numEq(a, b)
16+
if a == nil and b == nil then
17+
return true
18+
end
19+
if type(a) ~= "number" or type(b) ~= "number" then
20+
return a == b
21+
end
22+
if a ~= a and b ~= b then
23+
return true -- both NaN
24+
end
25+
return math.abs(a - b) <= math.max(1e-9, 1e-9 * math.max(math.abs(a), math.abs(b)))
26+
end
27+
28+
describe("TestParallelTasks", function()
29+
before_each(function()
30+
newBuild()
31+
end)
32+
33+
it("number sentinels survive the JSON boundary", function()
34+
local enc = PowerCalcTasks.encodeNumber
35+
local dec = PowerCalcTasks.decodeNumber
36+
assert.are.equals(5.25, dec(jsonRoundTrip({ v = enc(5.25) }).v))
37+
assert.are.equals(math.huge, dec(jsonRoundTrip({ v = enc(math.huge) }).v))
38+
assert.are.equals(-math.huge, dec(jsonRoundTrip({ v = enc(-math.huge) }).v))
39+
assert.are.equals(0, dec(jsonRoundTrip({ v = enc(0/0) }).v)) -- NaN guarded to 0
40+
assert.are.equals(nil, dec(nil))
41+
end)
42+
43+
it("nodePower worker batches merge to the same result as single-core PowerBuilder", function()
44+
local calcsTab = build.calcsTab
45+
calcsTab.powerStat = data.powerStatList[4] -- Combined DPS
46+
calcsTab.nodePowerMaxDepth = 4
47+
48+
-- Single-core reference (direct call; outside a coroutine it never yields)
49+
calcsTab:PowerBuilder()
50+
local refPower = { }
51+
for nodeId, node in pairs(build.spec.nodes) do
52+
refPower[nodeId] = {
53+
singleStat = node.power and node.power.singleStat,
54+
pathPower = node.power and node.power.pathPower,
55+
}
56+
end
57+
local refPowerMax = copyTable(calcsTab.powerMax)
58+
59+
-- Parallel simulation: enumerate, batch exactly as production does
60+
-- (including authoritative path data), compute, JSON round trip, merge
61+
local cand = calcsTab:EnumeratePowerCandidates()
62+
assert.True(cand.total > 0)
63+
local batches = calcsTab:BuildPowerBatches(cand, 3)
64+
local common = { powerStatIndex = 4, nodePowerMaxDepth = 4 }
65+
local payloads = { }
66+
for i, batch in ipairs(batches) do
67+
payloads[i] = jsonRoundTrip(PowerCalcTasks.nodePower.computeBatch(build, common, batch, "", nil))
68+
-- Worker payloads carry a baseline for divergence detection
69+
assert.is_table(payloads[i].baseline)
70+
end
71+
calcsTab:MergeParallelPower(payloads, build.outputRevision)
72+
73+
for nodeId, ref in pairs(refPower) do
74+
local node = build.spec.nodes[nodeId]
75+
assert.True(numEq(ref.singleStat, node.power and node.power.singleStat),
76+
"singleStat mismatch on node "..tostring(nodeId))
77+
assert.True(numEq(ref.pathPower, node.power and node.power.pathPower),
78+
"pathPower mismatch on node "..tostring(nodeId))
79+
end
80+
for key, value in pairs(refPowerMax) do
81+
assert.True(numEq(value, calcsTab.powerMax[key]), "powerMax mismatch on "..key)
82+
end
83+
end)
84+
85+
it("nodePower merge discards results from a stale build revision", function()
86+
local calcsTab = build.calcsTab
87+
calcsTab.powerBuildFlag = false
88+
calcsTab:MergeParallelPower({ }, build.outputRevision - 1)
89+
assert.True(calcsTab.powerBuildFlag)
90+
end)
91+
92+
it("notable measureOne matches the worker batch path", function()
93+
local itemsTab = build.itemsTab
94+
itemsTab.displayItem = new("Item", "Rarity: RARE\nTest Amulet\nJade Amulet\n+30 to Dexterity")
95+
itemsTab.anointEnchantSlot = 1
96+
97+
-- Collect a handful of anointable notables
98+
local nodeIds = { }
99+
for nodeId, node in pairs(build.spec.tree.nodes) do
100+
if node.recipe and #node.recipe >= 1 and node.modKey ~= "" then
101+
table.insert(nodeIds, nodeId)
102+
if #nodeIds >= 15 then
103+
break
104+
end
105+
end
106+
end
107+
assert.True(#nodeIds > 0)
108+
109+
-- Single-core reference via the shared measureOne
110+
local sortDetail = PowerCalcTasks.findSortDetail("CombinedDPS")
111+
local calcFunc = build.calcsTab:GetMiscCalculator()
112+
local itemType = itemsTab.displayItem.base.type
113+
local calcBase = calcFunc({ repSlotName = itemType, repItem = itemsTab:anointItem(nil) })
114+
local ref = { }
115+
for _, nodeId in ipairs(nodeIds) do
116+
ref[nodeId] = PowerCalcTasks.notable.measureOne(itemsTab, calcFunc, calcBase, sortDetail, itemType, build.spec.tree.nodes[nodeId])
117+
end
118+
119+
-- Worker path
120+
local payload = jsonRoundTrip(PowerCalcTasks.notable.computeBatch(build,
121+
{ sortMode = "CombinedDPS", anointEnchantSlot = 1 },
122+
{ nodeIds = nodeIds },
123+
itemsTab.displayItem:BuildRaw(), nil))
124+
for _, nodeId in ipairs(nodeIds) do
125+
local workerPower = PowerCalcTasks.decodeNumber(payload.results[tostring(nodeId)])
126+
assert.True(numEq(ref[nodeId], workerPower), "measuredPower mismatch on node "..tostring(nodeId))
127+
end
128+
end)
129+
130+
it("itemdb measureOne matches the worker batch path", function()
131+
-- Item DBs load over frames in the background
132+
local guard = 0
133+
while main.onFrameFuncs["LoadItems"] and guard < 1000 do
134+
runCallback("OnFrame")
135+
guard = guard + 1
136+
end
137+
local names = { }
138+
for name in pairs(main.uniqueDB.list) do
139+
table.insert(names, name)
140+
if #names >= 10 then
141+
break
142+
end
143+
end
144+
assert.True(#names > 0)
145+
146+
local itemsTab = build.itemsTab
147+
local sortDetail = PowerCalcTasks.findSortDetail("Life")
148+
local calcFunc = build.calcsTab:GetMiscCalculator()
149+
local ref = { }
150+
for _, name in ipairs(names) do
151+
ref[name] = PowerCalcTasks.itemdb.measureOne(itemsTab, calcFunc, sortDetail, sortDetail.sortMode, false, main.uniqueDB.list[name])
152+
end
153+
154+
local payload = jsonRoundTrip(PowerCalcTasks.itemdb.computeBatch(build,
155+
{ sortMode = "Life", dbType = "UNIQUE" },
156+
{ names = names }, "", nil))
157+
for _, name in ipairs(names) do
158+
local workerPower = PowerCalcTasks.decodeNumber(payload.results[name])
159+
assert.True(numEq(ref[name], workerPower), "measuredPower mismatch on item "..name)
160+
end
161+
end)
162+
163+
it("compare descriptors compute identically through the worker entry point", function()
164+
local primaryFile = io.open("../spec/TestBuilds/3.13/OccVortex.xml", "r")
165+
local compareFile = io.open("../spec/TestBuilds/3.13/Mirage Archer Toxic Rain.xml", "r")
166+
assert.is_not_nil(primaryFile)
167+
assert.is_not_nil(compareFile)
168+
local primaryXml = primaryFile:read("*a")
169+
primaryFile:close()
170+
local compareXml = compareFile:read("*a")
171+
compareFile:close()
172+
173+
loadBuildFromXML(primaryXml, "Primary")
174+
for i = 1, 5 do
175+
runCallback("OnFrame")
176+
end
177+
local compareTab = build.compareTab
178+
local compareEntry = new("CompareEntry", compareXml, "Comparison")
179+
compareEntry.xmlText = compareXml
180+
local powerStat = data.powerStatList[4] -- Combined DPS
181+
local categories = { treeNodes = true, items = true, skillGems = true, supportGems = true, config = true }
182+
183+
-- Single-core reference (run the real coroutine to completion)
184+
local co = coroutine.create(function()
185+
compareTab:ComparePowerBuilder(compareEntry, powerStat, categories)
186+
end)
187+
repeat
188+
local res, errMsg = coroutine.resume(co)
189+
assert.True(res, errMsg)
190+
until coroutine.status(co) == "dead"
191+
local refResults = compareTab.comparePowerResults
192+
assert.True(#refResults > 0)
193+
194+
-- Parallel simulation
195+
local descriptors = compareTab:EnumerateComparePowerCandidates(compareEntry, categories)
196+
for i, desc in ipairs(descriptors) do
197+
desc.i = i
198+
end
199+
local calcFunc, calcBase = compareTab.calcs.getMiscCalculator(build)
200+
local fmtCtx = compareTab:MakeComparePowerFormatContext(compareEntry, powerStat, calcBase)
201+
local resultByIndex = { }
202+
for _, slice in ipairs(ParallelRunner:PartitionList(descriptors, 3)) do
203+
local payload = jsonRoundTrip(PowerCalcTasks.compare.computeBatch(build,
204+
{ powerStatIndex = 4 }, { descriptors = slice }, compareXml, nil))
205+
for _, res in ipairs(payload.results) do
206+
resultByIndex[res.i] = res
207+
end
208+
end
209+
local parResults = { }
210+
for i, desc in ipairs(descriptors) do
211+
local res = resultByIndex[i]
212+
if res and res.ok and res.impact then
213+
local row = compareTab:BuildComparePowerRow(desc, PowerCalcTasks.decodeNumber(res.impact), res.extra or { }, fmtCtx)
214+
if row then
215+
table.insert(parResults, row)
216+
end
217+
end
218+
end
219+
220+
assert.are.equals(#refResults, #parResults)
221+
for i = 1, #refResults do
222+
assert.are.equals(refResults[i].category, parResults[i].category)
223+
assert.are.equals(refResults[i].name, parResults[i].name)
224+
assert.True(numEq(refResults[i].impact, parResults[i].impact), "impact mismatch on row "..i)
225+
assert.are.equals(refResults[i].combinedImpactStr, parResults[i].combinedImpactStr)
226+
end
227+
end)
228+
end)

0 commit comments

Comments
 (0)