ludic build on every core: the IR split with llvm-split and compiled by a clang per part at once

Most of a build was one clang -O2 on one .ll (the game: 32 s of a 41 s headless build, one core).
Both paths that assemble - the CLI's (build.ludic: ludicc --emit-llvm, then clang) and ludicc's own
(-o, which ludic bundle and the examples use) - now cut the program's IR into N parts with llvm-split
(externalizing what the parts share), compile them with one clang each in parallel (-x ir -O<opt>
-mmacosx-version-min=11.0, the link's own clang taking the objects where it took the .ll), and remove
the parts and objects after. N is $LUDIC_JOBS, else min(cores, free GB / 1.5).

It needs an llvm-split and a clang of the same LLVM (Homebrew's LLVM 22 writes attributes Apple's
clang 17 cannot read): $LUDIC_LLVM, else /opt/homebrew/opt/llvm/bin. With either missing, on Windows
(its shell cannot run the parts at once yet), with LUDIC_SPLIT=0, or when a part fails, it compiles the
.ll whole as before.

$LUDIC_OPT=1 is a developer's faster build; ludic bundle sets LUDIC_OPT=2 for its compile whatever the
shell says.

Measured before the compile-only rule (this Mac, 12 cores, one build at a time):
- the game headless: 37-41 s -> 13-15.5 s (8 parts / by free memory), peak 2.1 GB -> 1.0-1.1 GB;
- the lab headless: 43.1 s -> 12.7 s, peak 2.4 GB -> 1.0 GB;
- the game at LUDIC_OPT=1, split: 11.8 s (fps cost not measured).
Both built and linked clean; the goldens and a headless shot of the result are not run here.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Orkun ÇAKILKAYA 2026-09-29 17:12:27 +03:00
parent 9f94be7cca
commit 9cc3f24c56
10 changed files with 4119 additions and 2288 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load diff

View file

@ -280,7 +280,11 @@ entry {
# -Wno-override-module: the emitted IR carries no target triple, so clang # -Wno-override-module: the emitted IR carries no target triple, so clang
# substitutes the host's and warns about it on every single build. There is # substitutes the host's and warns about it on every single build. There is
# nothing to act on — the host default is exactly what we want — so it is off. # nothing to act on — the host default is exactly what we want — so it is off.
var cmd = `{cc} -O2 -Wno-override-module {ll}` # $LUDIC_OPT=1 is a developer's faster build (never a bundle's: ludic bundle sets 2)
let opt = getenv_or("LUDIC_OPT", "2")
let objs = split_compile(ll, out, opt) # on every core when it can (split_build.ludic)
var cmd = `{cc} -O{opt} -Wno-override-module {ll}`
if objs != null { cmd = `{cc} -O{opt} -Wno-override-module{objs}` }
if g_target_win { if g_target_win {
# The Windows window: win32.ll (user32, gdi32 for the software present, xinput # The Windows window: win32.ll (user32, gdi32 for the software present, xinput
# for pads), and audio_win.ll when the program uses Audio.*, as on macOS. # for pads), and audio_win.ll when the program uses Audio.*, as on macOS.
@ -389,6 +393,7 @@ entry {
var line = cmd var line = cmd
if host_is_windows() { line = `"{cmd}"` } if host_is_windows() { line = `"{cmd}"` }
let rc = run(line) let rc = run(line)
if objs != null { split_clean(out) }
if not save { remove_file(ll) } if not save { remove_file(ll) }
if rc != 0 { die("ludicc: link failed\n") } if rc != 0 { die("ludicc: link failed\n") }
if natives_out != null { if natives_out != null {

View file

@ -0,0 +1,87 @@
# split_build.ludic - the program's IR compiled on every core: llvm-split cuts it into N parts, one
# clang each runs at once, and the link takes their objects where it took the one .ll. Most of a
# build was one clang -O2 on one file (the game: 32 s of 41). Needs an llvm-split and a clang of
# the same LLVM (a newer LLVM's parts do not read in an older clang): $LUDIC_LLVM (a bin directory),
# else Homebrew's. Anything missing, or a part that does not compile, falls back to the one clang.
function split_there(p: pointer) -> bool {
let f = file_open(p, "rb")
if f == null { return false }
file_close(f)
return true
}
# the LLVM bin directory to split with, or null (Windows' shell cannot run the parts at once yet)
function split_dir() -> pointer {
if host_is_windows() { return null }
if getenv_or("LUDIC_SPLIT", "1") == "0" { return null }
let d = getenv_or("LUDIC_LLVM", "/opt/homebrew/opt/llvm/bin")
if not split_there(`{d}/llvm-split`) or not split_there(`{d}/clang`) { return null }
return d
}
# how many parts: $LUDIC_JOBS, else the cores, no more than the free memory allows at 1.5 GB a clang
function split_jobs(tmp: pointer) -> int {
let j = getenv("LUDIC_JOBS")
if j != null { return split_int(j) }
let probe = `{tmp}.cores`
let aw = "awk '/Pages free/{f=$3} /Pages inactive/{i=$3} END{print int((f+i)*p/1073741824)}'"
run(`echo $(sysctl -n hw.ncpu) $(vm_stat | {aw} p=$(sysctl -n hw.pagesize)) > "{probe}" 2>/dev/null`)
let f = file_open(probe, "rb")
if f == null { return 1 }
let buf = bytes(64)
let n = file_read(f, buf, 63)
file_close(f)
remove_file(`"{probe}"`)
if n <= 0 { return 1 }
buf[n] = 0
var cores = 0
var i = 0
while i < n and buf[i] >= '0' and buf[i] <= '9' { cores = cores * 10 + (buf[i] - '0'); i += 1 }
while i < n and buf[i] == ' ' { i += 1 }
var gb = 0
while i < n and buf[i] >= '0' and buf[i] <= '9' { gb = gb * 10 + (buf[i] - '0'); i += 1 }
let by_mem = (gb * 2) / 3
if by_mem < cores { return by_mem }
return cores
}
function split_int(s: pointer) -> int {
var v = 0
var i = 0
while i < len(s) and s[i] >= '0' and s[i] <= '9' { v = v * 10 + (s[i] - '0'); i += 1 }
return v
}
# the IR at `ll` compiled in parts at -O`opt`: their objects, quoted and space-separated, for the
# link to take in place of the .ll; null when it was not split (the caller compiles the .ll whole)
function split_compile(ll: pointer, out: pointer, opt: pointer) -> pointer {
let d = split_dir()
if d == null { return null }
let n = split_jobs(out)
if n < 2 { return null }
let pre = `{out}.part`
if run(`"{d}/llvm-split" -j {n} -o "{pre}" "{ll}"`) != 0 { return null }
var sh = ""
var i = 0
while i < n {
sh = sh + `"{d}/clang" -x ir -O{opt} -mmacosx-version-min=11.0 -Wno-override-module -Wno-missing-sysroot -c "{pre}{itoa(i)}" -o "{pre}{itoa(i)}.o" & `
i += 1
}
run(`/bin/sh -c '{sh}wait'`)
var objs = ""
var ok = true
i = 0
while i < n {
remove_file(`"{pre}{itoa(i)}"`)
if not split_there(`{pre}{itoa(i)}.o`) { ok = false }
objs = objs + ` "{pre}{itoa(i)}.o"`
i += 1
}
if not ok {
split_clean(out)
return null
}
return objs
}
# the parts' objects, once linked (or when a part failed)
function split_clean(out: pointer) -> void { run(`rm -f "{out}".part*.o`) }

View file

@ -54,7 +54,12 @@ function compile_app(src: pointer, out: pointer, mode: int, save: bool) -> bool
if mode == 2 { if mode == 2 {
if not shq(`{ludicc()} --headless{title_flag()}{unsafe_flag()} {src} --emit-llvm -o {ll}`) { return false } if not shq(`{ludicc()} --headless{title_flag()}{unsafe_flag()} {src} --emit-llvm -o {ll}`) { return false }
if not shq(`{cc()} -O2 {ll}{gl_link_flags(ll)}{vk_link_flags(ll)}{http_link_flags(ll)}{udp_link_flags(ll)}{process_link_flags(ll)}{threads_link_flags(ll)}{native_link_flags(ll)}{pbf} -o {out}`) { return false } let objs = split_objs(ll, out) # on every core when it can (split.ludic)
var input = ll
if objs != "" { input = objs }
let linked = shq(`{cc()} -O{split_opt()} {input}{gl_link_flags(ll)}{vk_link_flags(ll)}{http_link_flags(ll)}{udp_link_flags(ll)}{process_link_flags(ll)}{threads_link_flags(ll)}{native_link_flags(ll)}{pbf} -o {out}`)
if objs != "" { shell(`rm -f {objs}`) }
if not linked { return false }
g_built_natives = native_libs_of(ll) g_built_natives = native_libs_of(ll)
if not save { shell(`rm -f {ll}`) } if not save { shell(`rm -f {ll}`) }
return true return true
@ -65,7 +70,12 @@ function compile_app(src: pointer, out: pointer, mode: int, save: bool) -> bool
# canonical `ludicc -o` path links it only when Audio.* is used. # canonical `ludicc -o` path links it only when Audio.* is used.
let cocoa = `{home}runtime/native/cocoa.ll` let cocoa = `{home}runtime/native/cocoa.ll`
let audio = `{home}runtime/native/audio.ll` let audio = `{home}runtime/native/audio.ll`
if not shq(`{cc()} -O2 {ll} {cocoa} {audio} -framework Cocoa -Wl,-needed_framework,GameController -Wl,-needed_framework,AVFoundation -Wl,-rpath,@loader_path{gl_link_flags(ll)}{vk_link_flags(ll)}{http_link_flags(ll)}{udp_link_flags(ll)}{process_link_flags(ll)}{threads_link_flags(ll)}{native_link_flags(ll)}{pbf} -o {out}`) { return false } let objs = split_objs(ll, out)
var input = ll
if objs != "" { input = objs }
let linked = shq(`{cc()} -O{split_opt()} {input} {cocoa} {audio} -framework Cocoa -Wl,-needed_framework,GameController -Wl,-needed_framework,AVFoundation -Wl,-rpath,@loader_path{gl_link_flags(ll)}{vk_link_flags(ll)}{http_link_flags(ll)}{udp_link_flags(ll)}{process_link_flags(ll)}{threads_link_flags(ll)}{native_link_flags(ll)}{pbf} -o {out}`)
if objs != "" { shell(`rm -f {objs}`) }
if not linked { return false }
g_built_natives = native_libs_of(ll) g_built_natives = native_libs_of(ll)
if not save { shell(`rm -f {ll}`) } if not save { shell(`rm -f {ll}`) }
return true return true

View file

@ -337,6 +337,7 @@ function cmd_bundle_windows() -> int {
let staged = tmp_path("bundle_exe") let staged = tmp_path("bundle_exe")
var flags = "--windowed --gui" var flags = "--windowed --gui"
if res != "" { flags = flags + ` --link "{res}"` } if res != "" { flags = flags + ` --link "{res}"` }
Os.set_env("LUDIC_OPT", "2") # a shipped game is -O2, whatever a developer's shell says
if not shq(`{ludicc()} {flags}{title_flag()} {entry} --natives-out {staged}.natives -o {staged}`) { return 1 } if not shq(`{ludicc()} {flags}{title_flag()} {entry} --natives-out {staged}.natives -o {staged}`) { return 1 }
if not shq(`cp "{staged}.exe" "{root}/{name}.exe"`) { if not shq(`cp "{staged}.exe" "{root}/{name}.exe"`) {
err("ludic bundle: could not place the executable\n") err("ludic bundle: could not place the executable\n")

View file

@ -20,6 +20,7 @@
program LudicDev { program LudicDev {
import "prelude.ludic" import "prelude.ludic"
import "build.ludic" import "build.ludic"
import "split.ludic"
import "toolchain.ludic" import "toolchain.ludic"
import "selfhost.ludic" import "selfhost.ludic"
import "tools.ludic" import "tools.ludic"

View file

@ -22,6 +22,7 @@
program Ludic { program Ludic {
import "prelude.ludic" import "prelude.ludic"
import "build.ludic" import "build.ludic"
import "split.ludic"
import "project.ludic" import "project.ludic"
import "pkg.ludic" import "pkg.ludic"
import "assets.ludic" import "assets.ludic"

View file

@ -109,6 +109,7 @@ function selfhost_frags() -> []pointer {
push(f, "selfhost/backend/stdlib/emit_net.ludic") push(f, "selfhost/backend/stdlib/emit_net.ludic")
push(f, "selfhost/backend/game/emit_ui.ludic") push(f, "selfhost/backend/game/emit_ui.ludic")
push(f, "selfhost/backend/emit_decl.ludic") push(f, "selfhost/backend/emit_decl.ludic")
push(f, "selfhost/split_build.ludic")
push(f, "selfhost/main.ludic") push(f, "selfhost/main.ludic")
return f return f
} }

View file

@ -0,0 +1,49 @@
# split.ludic - `ludic build` on every core: the program's IR cut into N parts (llvm-split), a clang
# each at once, their objects linked where the one .ll was (the game: clang was 32 s of a 41 s
# build, on one core). ludicc's own link does the same (selfhost/split_build.ludic). It needs an
# llvm-split and a clang of one LLVM - $LUDIC_LLVM, else Homebrew's - or it compiles the .ll whole.
function split_opt() -> pointer { return getenv_or("LUDIC_OPT", "2") } # 1: a developer's faster build
# " a.o b.o ..." for the link, or "" when not split (the caller compiles the .ll as before)
function split_objs(ll: pointer, out: pointer) -> pointer {
if host_windows() or getenv_or("LUDIC_SPLIT", "1") == "0" { return "" }
let d = getenv_or("LUDIC_LLVM", "/opt/homebrew/opt/llvm/bin")
if not is_exec(`"{d}/llvm-split"`) or not is_exec(`"{d}/clang"`) { return "" }
let n = split_n()
if n < 2 { return "" }
let pre = tmp_path(`{file_of_path(out)}.part`)
if not shq(`"{d}/llvm-split" -j {n} -o "{pre}" "{ll}"`) { return "" }
var sh = ""
for i in 0 .. n {
sh = sh + `"{d}/clang" -x ir -O{split_opt()} -mmacosx-version-min=11.0 -Wno-override-module -Wno-missing-sysroot -c "{pre}{i}" -o "{pre}{i}.o" & `
}
shell(`{sh}wait`)
var objs = ""
var ok = true
for i in 0 .. n {
shell(`rm -f "{pre}{i}"`)
if not file_exists(`"{pre}{i}.o"`) { ok = false }
objs = objs + ` "{pre}{i}.o"`
}
if ok { return objs }
shell(`rm -f {objs}`)
return ""
}
# $LUDIC_JOBS, else the cores - no more than the free memory allows at 1.5 GB a clang
function split_n() -> int {
let j = getenv("LUDIC_JOBS")
if j != null { return s_to_int(j) }
let aw = "awk '/Pages free/{f=$3} /Pages inactive/{i=$3} END{print int((f+i)*p/1073741824)}'"
let line = capture_line(`echo $(sysctl -n hw.ncpu) $(vm_stat | {aw} p=$(sysctl -n hw.pagesize))`)
var cores = 0
var i = 0
while i < len(line) and line[i] >= '0' and line[i] <= '9' { cores = cores * 10 + (line[i] - '0'); i += 1 }
while i < len(line) and line[i] == ' ' { i += 1 }
var gb = 0
while i < len(line) and line[i] >= '0' and line[i] <= '9' { gb = gb * 10 + (line[i] - '0'); i += 1 }
let by_mem = (gb * 2) / 3
if by_mem < cores { return by_mem }
return cores
}