fix(lang): the action drain is the program's; --check runs every check a build makes; a registry key named count is refused; name lookups are tables

- the function that calls every reducer (and the action queue) is written in the program's own
  file: in the first action's file it belonged to that module, depended on every module with a
  reducer, and joined a game's modules into one 69-module cycle (examples/actions/modules and a
  ludic deps case hold it); the state instances, the queue and the reducers make no deps edges
- ludicc --check / ludic build --check lower the program too and write nothing, so the code
  writer's refusals are in it: a bind to a function that is gone, an unknown name (and the checker
  now refuses fn <missing> itself); rejects bind_missing_fn, unknown_name, registry_count_key
- def R count is refused: its constant would be PREFIX_COUNT, the registry's size
- a file's module, package, trust and numbers-float are tables, and from the check on the lookups
  of functions, enums, records, globals and externs are too (tagged enums kept as a list): Maroon
  Lake's check-only build went from about 20 s to 7 s including lowering, its IR from about 2
  minutes to under 10 s; duplicate declarations are found by table, not a pair of loops
- threads.ludic's pool check gives each call a little work, so a busy machine cannot run them all
  on the caller before a worker wakes (it failed one run in three under load)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Orkun ÇAKILKAYA 2026-09-25 23:34:34 +03:00
parent 2fdefa6040
commit 8cf4b3fed6
24 changed files with 67169 additions and 64398 deletions

View file

@ -151,7 +151,94 @@ function find_arch(name: pointer) -> Node {
while i < len(prog) { let d = prog[i]; if d.kind == N_ARCH and (d.s == name) { return d }; i += 1 }
return null
}
# ---- the emitter's lookups by name: tables, once the program is whole ---------------------
# find_fn / find_enum / find_comp / find_global / find_extern are asked for every name the emitter
# lowers; walking prog for each was most of a big program's build. From emit_program on, each kind
# is a table, kept up with what is appended to prog; a hit whose node has since been renamed falls
# back to the walk.
const IX_SLOTS: int = 131072
var g_ix_on: bool = false
var g_ix_tagged: []Node = new []Node
var g_ix_upto: int = 0
var g_ix_keys: [][]pointer = new [][]pointer # per kind: 0 fn, 1 enum, 2 comp, 3 global, 4 extern
var g_ix_vals: [][]pointer = new [][]pointer
function ix_start() -> void {
g_ix_keys = new [][]pointer
g_ix_vals = new [][]pointer
var t = 0
while t < 5 {
let k = new []pointer
let v = new []pointer
var i = 0
while i < IX_SLOTS {
push(k, null)
push(v, null)
i += 1
}
push(g_ix_keys, k)
push(g_ix_vals, v)
t += 1
}
g_ix_upto = 0
g_ix_tagged = new []Node
g_ix_on = true
}
function ix_kind(d: Node) -> int {
if d.kind == N_FN { return 0 }
if d.kind == N_ENUM { return 1 }
if d.kind == N_COMP { return 2 }
if d.kind == N_VAR or d.kind == N_CONST { return 3 }
if d.kind == N_EXTERN { return 4 }
return -1
}
function ix_slot(t: int, name: pointer) -> int {
var h = 5381
var i = 0
let n = len(name)
while i < n {
h = (h * 33 + name[i]) % 1000003
i += 1
}
h = h % IX_SLOTS
let ks = g_ix_keys[t]
while ks[h] != null and not (ks[h] == name) { h = (h + 1) % IX_SLOTS }
return h
}
function ix_sync() -> void {
while g_ix_upto < len(prog) {
let d = prog[g_ix_upto]
let t = ix_kind(d)
if d.kind == N_ENUM and enum_is_tagged(d) { push(g_ix_tagged, d) }
if t >= 0 and d.s != null {
let h = ix_slot(t, d.s)
if g_ix_keys[t][h] == null { # the first of a name wins, as the walk had it
g_ix_keys[t][h] = d.s
g_ix_vals[t][h] = d
}
}
g_ix_upto += 1
}
}
# the node of kind t called name, or null; -1 when the table cannot say (off, or a stale hit)
var g_ix_miss: bool = false
function ix_find(t: int, name: pointer) -> Node {
g_ix_miss = false
if not g_ix_on or name == null {
g_ix_miss = true
return null
}
ix_sync()
let h = ix_slot(t, name)
let p = g_ix_vals[t][h]
if p == null { return null }
let d: Node = p
if (d.s == name) and ix_kind(d) == t { return d }
g_ix_miss = true
return null
}
function find_comp(name: pointer) -> Node {
let x = ix_find(2, name)
if not g_ix_miss { return x }
var i = 0
while i < len(prog) { let d = prog[i]; if d.kind == N_COMP and (d.s == name) { return d }; i += 1 }
return null
@ -174,6 +261,8 @@ function field_type(s: Node, fname: pointer) -> pointer {
# find a global var/const by name
function find_global(name: pointer) -> Node {
let x = ix_find(3, name)
if not g_ix_miss { return x }
var i = 0
while i < len(prog) {
let d = prog[i]
@ -188,6 +277,14 @@ function find_global(name: pointer) -> Node {
# enum with that variant. Enum names live in `prog` like any other declaration.
function enum_ordinal(ename: pointer, vname: pointer) -> int {
var i = 0
if g_ix_on { # the table knows the one enum of that name
let d = find_enum(ename)
if d != null {
var j = 0
while j < len(d.kids) { if (d.kids[j].s == vname) { return j }; j += 1 }
}
i = len(prog)
}
while i < len(prog) {
let d = prog[i]
if d.kind == N_ENUM and (d.s == ename) {
@ -234,6 +331,8 @@ function has_countdowns() -> bool {
return false
}
function find_fn(name: pointer) -> Node {
let x = ix_find(0, name)
if not g_ix_miss { return x }
var i = 0
while i < len(prog) { let d = prog[i]; if d.kind == N_FN and (d.s == name) { return d }; i += 1 }
return null
@ -248,6 +347,8 @@ function find_fn(name: pointer) -> Node {
var g_var_ord: int = 0
function find_enum(name: pointer) -> Node {
let x = ix_find(1, name)
if not g_ix_miss { return x }
var i = 0
while i < len(prog) { let d = prog[i]; if d.kind == N_ENUM and (d.s == name) { return d }; i += 1 }
return null
@ -276,6 +377,16 @@ function variant_ordinal(en: Node, vname: pointer) -> int {
# Only tagged enums participate, so a plain enum's `Enum.Variant` member form and
# any like-named function are left alone.
function variant_enum(vname: pointer) -> Node {
if g_ix_on { # the tagged enums, kept as prog grows
ix_sync()
var t = 0
while t < len(g_ix_tagged) {
let ord = variant_ordinal(g_ix_tagged[t], vname)
if ord >= 0 { g_var_ord = ord; return g_ix_tagged[t] }
t += 1
}
return null
}
var i = 0
while i < len(prog) {
let d = prog[i]
@ -306,6 +417,8 @@ function enum_box_size(en: Node) -> int { return 8 * (1 + enum_max_arity(en)) }
# is the transport seam (net_send/net_poll), the windowing/socket FFI, and any
# C/Rust/Zig library binding — the same seam NETWORKING-DESIGN §5 names.
function find_extern(name: pointer) -> Node {
let x = ix_find(4, name)
if not g_ix_miss { return x }
var i = 0
while i < len(prog) { let d = prog[i]; if d.kind == N_EXTERN and (d.s == name) { return d }; i += 1 }
return null

View file

@ -213,19 +213,19 @@ function emit_test_runner() -> void {
# two `function`s with one name would collide in the object file; say so in
# source terms (and name both files) instead of leaving it to the IR assembler.
function check_duplicate_fns() -> void {
let k = new []pointer # by name, in a table: a pair of loops was quadratic
let v = new []Node
ck_tab_init(k, v)
var i = 0
while i < len(prog) {
let d = prog[i]
if d.kind == N_FN {
var j = i + 1
while j < len(prog) {
let o = prog[j]
if o.kind == N_FN and (o.s == d.s) {
g_err_file = o.file; g_err_line = o.line
perr(`function '{d.s}' is defined twice (first in {d.file}:{itoa(d.line)})`)
}
j += 1
let first = ck_tab_get(k, v, d.s)
if first != null {
g_err_file = d.file; g_err_line = d.line
perr(`function '{d.s}' is defined twice (first in {first.file}:{itoa(first.line)})`)
}
ck_tab_put(k, v, d.s, d)
}
i += 1
}
@ -240,22 +240,23 @@ function decl_group(k: int) -> int {
return 0
}
function check_duplicate_decls() -> void {
let k = new []pointer
let v = new []Node
ck_tab_init(k, v)
let n = g_prog_user_end
var i = 0
while i < n and i < len(prog) {
let d = prog[i]
let g = decl_group(d.kind)
if g > 0 {
var j = i + 1
while j < n and j < len(prog) {
let o = prog[j]
if decl_group(o.kind) == g and (o.s == d.s) {
g_err_file = o.file
g_err_line = o.line
perr(`'{d.s}' is defined twice (first in {d.file}:{itoa(d.line)})`)
}
j += 1
let key = `{itoa(g)}:{d.s}`
let first = ck_tab_get(k, v, key)
if first != null {
g_err_file = d.file
g_err_line = d.line
perr(`'{d.s}' is defined twice (first in {first.file}:{itoa(first.line)})`)
}
ck_tab_put(k, v, key, d)
}
i += 1
}
@ -264,6 +265,7 @@ function check_duplicate_decls() -> void {
function emit_program() -> void {
check_duplicate_fns()
check_duplicate_decls()
ix_start() # the lookups by name, as tables from here on
head = buf_new()
code = buf_new()
g_uses_str = false

View file

@ -29,17 +29,29 @@ function vis_plain(what: pointer) -> pointer {
function vis_say(m: pointer) -> void {
file_write(file_stderr(), m, len(m))
}
# does s start with p - without making the slice a comparison of s[0 .. n] would (asked for every
# reference)
function str_prefix(s: pointer, p: pointer) -> bool {
if s == null { return false }
let n = len(p)
if len(s) < n { return false }
var i = 0
while i < n {
if s[i] != p[i] { return false }
i += 1
}
return true
}
function vis_check(d: Node, what0: pointer) -> void {
if d == null { return }
port_check_bound(d)
# 0.S/0.R: a state's instance, the action queue and a reducer are the runtime's to supply and to
# call - the code that names them depends on no module for it
if d.kind == N_VAR and d.uns == 1 and str_prefix(d.s, "state$") and is_state_ty(d.ty) { return }
if d.kind == N_FN and (str_prefix(d.s, "ludic_reduce__") or is_action_builtin(d.s)) { return }
deps_edge(d, what0) # LUDIC_DEPS: the graph ludic deps reads (emit_deps.ludic)
if g_vis_off { return }
if d.file == null { return }
# 0.S: a state's instance, supplied by the runtime (a component's or a view's glue, an entry's
# parameters): the code that names it only passes it on
if d.kind == N_VAR and d.uns == 1 and is_state_ty(d.ty) and len(d.s) > 6 and (d.s[0 .. 6] == "state$") { return }
# 0.R: the action queue and the reducers are the runtime's to call, from any module
if d.kind == N_FN and (is_action_builtin(d.s) or (len(d.s) > 14 and (d.s[0 .. 14] == "ludic_reduce__"))) { return }
let what = vis_plain(what0)
var here = g_err_file
if here == null { return }

View file

@ -49,6 +49,9 @@ function ck_expr(e: Node) -> pointer {
let f = ck_fn(e.s)
ck_vis(f, e.s, e)
if f != null { return fn_sig_of(f) }
# the emitter's own refusal, here too, so a check-only build finds it (a bind to a function
# that is gone)
if find_fn(e.s) == null { ck_err("fnref", e, `fn {e.s}: no function called {e.s}`) }
return "?"
}
if k == E_NEW { return ck_new(e) }

View file

@ -19,6 +19,7 @@ var g_red_action: []pointer = new []pointer
var g_dsp_ids: []Node = new []Node # each dispatch's action number, filled at the end
var g_dsp_names: []pointer = new []pointer
var g_dsp_at: []Node = new []Node
var g_act_main: pointer = null # the program's own file, where the drain is written
const ACTION_PASSES: int = 64
# action NAME { fields } - a record
@ -101,12 +102,11 @@ function actions_finish() -> void {
g_dsp_ids[i].ival = k
i += 1
}
# the queue and the drain are the program's, written in its own file: in the file of the first
# action they would belong to its module, and it would call every module's reducers
var file = g_parse_file
var line = 1
if len(g_act_nodes) > 0 {
file = g_act_nodes[0].file
line = g_act_nodes[0].line
}
if g_act_main != null { file = g_act_main }
let line = 1
# no actions: drain_actions() is still there (a ludic.base runner calls it), and does nothing
if len(g_act_names) == 0 {
vw_parse("export function drain_actions() -> void {\n}\n", file, line, 1)

View file

@ -128,15 +128,9 @@ function pkg_name_of(rel: pointer) -> pointer {
function pkg_file_set(f: pointer, name: pointer) -> void {
push(g_pk_file, f)
push(g_pk_name, name)
fm_put(g_fm_pkg, f, name)
}
function pkg_of_file(f: pointer) -> pointer {
var i = len(g_pk_file) - 1
while i >= 0 {
if (g_pk_file[i] == f) { return g_pk_name[i] }
i -= 1
}
return ""
}
function pkg_of_file(f: pointer) -> pointer { return fm_get(g_fm_pkg, f) }
# the module a declaration is in, for the uses rule: its own, else its package's
function module_for_uses(f: pointer) -> pointer {
let m = module_of(f)

View file

@ -25,36 +25,77 @@ var g_trusted_files: []pointer = new []pointer
var g_unsafe_all: bool = false
function unsafe_trusted(f: pointer) -> bool {
if g_unsafe_all { return true }
return not (fm_get(g_fm_trusted, f) == "")
}
# a file's facts - its module, its package, whether it is trusted - asked of every reference the
# checker and the emitter resolve: a table by file name, not a list walked each time (with a game's
# nine hundred files that walk was most of a check-only build)
const FM_SLOTS: int = 16384
property FileMap {
keys: []pointer = null
vals: []pointer = null
}
function fm_new() -> FileMap {
let m = new FileMap
m.keys = new []pointer
m.vals = new []pointer
var i = 0
while i < len(g_trusted_files) {
if (g_trusted_files[i] == f) { return true }
while i < FM_SLOTS {
push(m.keys, null)
push(m.vals, null)
i += 1
}
return false
return m
}
function fm_slot(m: FileMap, f: pointer) -> int {
var h = 5381
var i = 0
let n = len(f)
while i < n {
h = (h * 33 + f[i]) % 1000003
i += 1
}
h = h % FM_SLOTS
while m.keys[h] != null and not (m.keys[h] == f) { h = (h + 1) % FM_SLOTS }
return h
}
# the latest value set for f wins, as the lists' walk from the end had it
function fm_put(m: FileMap, f: pointer, v: pointer) -> void {
if f == null { return }
let h = fm_slot(m, f)
m.keys[h] = f
m.vals[h] = v
}
function fm_get(m: FileMap, f: pointer) -> pointer {
if f == null { return "" }
let h = fm_slot(m, f)
if m.keys[h] == null { return "" }
return m.vals[h]
}
var g_fm_mod: FileMap = fm_new()
var g_fm_pkg: FileMap = fm_new()
var g_fm_trusted: FileMap = fm_new()
function trust_file(f: pointer) -> void {
push(g_trusted_files, f)
fm_put(g_fm_trusted, f, "1")
}
var g_mod_file: []pointer = new []pointer
var g_mod_name: []pointer = new []pointer
var g_mod_friends: []pointer = new []pointer
function module_of(f: pointer) -> pointer {
var i = len(g_mod_file) - 1
while i >= 0 {
if (g_mod_file[i] == f) { return g_mod_name[i] }
i -= 1
}
return ""
}
function module_of(f: pointer) -> pointer { return fm_get(g_fm_mod, f) }
function module_set(f: pointer, name: pointer) -> void {
push(g_mod_file, f)
push(g_mod_name, name)
fm_put(g_fm_mod, f, name)
}
function is_float_file(f: pointer) -> bool {
if (f == null) { return false }
var i = 0
while i < len(g_float_files) {
if (g_float_files[i] == f) { return true }
i += 1
}
return false
return not (fm_get(g_fm_float, f) == "")
}
var g_fm_float: FileMap = fm_new()
function float_file_add(f: pointer) -> void {
push(g_float_files, f)
fm_put(g_fm_float, f, "1")
}
var g_parsing: bool = true # false once lowering starts
var g_err_file: pointer = "" # the statement being lowered
@ -952,7 +993,7 @@ function parse_one_decl() -> void {
}
if is_id("numbers") and (toks[pi + 1].text == "float") {
pi += 2
if not is_float_file(g_parse_file) { push(g_float_files, g_parse_file) }
if not is_float_file(g_parse_file) { float_file_add(g_parse_file) }
return
}
if is_id("import") { pi += 1
@ -1180,11 +1221,11 @@ function do_import(rel: pointer) -> void {
}
if already_loaded(full) { return }
push(loaded_paths, full)
if is_float_file(g_parse_file) and not is_runtime_path(rel) and not is_float_file(full) { push(g_float_files, full) }
if is_float_file(g_parse_file) and not is_runtime_path(rel) and not is_float_file(full) { float_file_add(full) }
# a module reaches as far as its own files: a package found through $LUDIC_HOME or
# ludic_modules is not beside its importer and keeps its own module (or none)
let beside = full == join_path(cur_dir, rel)
if is_runtime_path(rel) or not beside or (beside and unsafe_trusted(g_parse_file)) { push(g_trusted_files, full) }
if is_runtime_path(rel) or not beside or (beside and unsafe_trusted(g_parse_file)) { trust_file(full) }
if beside and not is_runtime_path(rel) and not (module_of(g_parse_file) == "") { module_set(full, module_of(g_parse_file)) }
if not beside and not is_runtime_path(rel) { pkg_file_set(full, pkg_name_of(rel)) } # modules.ludic
if beside and not is_runtime_path(rel) and not (pkg_of_file(g_parse_file) == "") { pkg_file_set(full, pkg_of_file(g_parse_file)) }

View file

@ -44,6 +44,12 @@ function reg_fill(r: int) -> void {
j += 1
}
push(seen, key)
if (reg_upper(key) == "COUNT") {
g_err_file = rec.file
g_err_line = rec.line
g_parsing = false
perr(`def {g_rg_name[r]} {key}: its constant would be {g_rg_prefix[r]}_COUNT, which is the registry's size; give the entry another key`)
}
if keyed and not reg_rec_has(rec, "key") {
let fi = node(E_FINIT)
fi.s = "key"

View file

@ -29,7 +29,7 @@ function res_read_into(reg: pointer, from: pointer, who: pointer) -> void {
let saved_file = g_parse_file
let saved_parsing = g_parsing
if not (module_of(saved_file) == "") { module_set(path, module_of(saved_file)) }
if is_float_file(saved_file) and not is_float_file(path) { push(g_float_files, path) }
if is_float_file(saved_file) and not is_float_file(path) { float_file_add(path) }
g_parse_file = path
g_parsing = true
g_res_mode = true

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -188,6 +188,7 @@ entry {
cur_dir = dir_of(path)
g_src_name = base_name(path) # for panic/expect file:line messages
g_parse_file = path # for the compiler's own file:line diagnostics
g_act_main = path # 0.R: where the action drain is written
lex(src)
parse_program()
g_prog_user_end = len(prog)
@ -217,9 +218,15 @@ entry {
check_duplicate_decls()
g_mg_entry = path
if g_migrate { mg_collect() } # 0.S2: the vars that move (migrate.ludic)
ix_start() # the lookups by name, as tables (emit_core.ludic)
check_program() # L4: the types agree before anything is emitted
if g_migrate { mg_finish() }
if g_check_only { exit(0) } # --check: the checks are all there is
# --check: every check a build makes - the emitter refuses things too (a bind to a function that is
# gone, an unknown name) - and nothing written
if g_check_only {
emit_program()
exit(0)
}
emit_program()
deps_flush() # LUDIC_DEPS=<file>: the module graph (ludic deps)