Repository navigation
Every open bug, batch 5: clause sets, branch locals, closures, module scope, and a fair benchmark suite - #3085
Merged
Merged
Conversation
…y, and serves its files Found running `make benchmark` on Windows (MSYS2 MINGW64) at df5540e: - Every platform: the Aether benchmarks did not link. The generated C calls std (string_retain, string_release, aether_write_bytes) and the bench Makefile compiled the runtime sources alone, so every Aether entry was "[SKIP] Build failed" and the runner stopped. It links the rest through `ae cflags --libs`, as it takes its includes from `ae cflags --cflags`; the runtime is still compiled from source with the benchmark's flags, so only std members come from the archive. - Every platform: parse_mem matched "maximum resident set size", macOS's spelling; GNU time -v writes "Maximum ...", so every Linux run recorded 0 MB. - Windows: os_system and os_exec_raw hand a command to cmd.exe, and every command here is POSIX sh. On Windows they run through sh -c, argv-style through os.run_full (sh_system, sh_exec), chosen by select(windows: ...). The PATH prefix, `:`-separated POSIX paths, is not added there (it corrupted the first entry of a `;` PATH), and the CPU name comes from /proc/cpuinfo, which Linux and MSYS both have (MSYS has no lscpu). - Windows: the visualization server read its files under `pwd`, an MSYS path (/d/...) no file read opens, so the page was a 404 and every result "No results yet". It reads them from its working directory, where `make benchmark` starts it. Measured: make benchmark on an i7-13700K runs Aether, C, C++, Go and Rust through all five patterns and serves the page; Java, Zig 0.16 (the sources need an older Zig), Elixir, Erlang, Pony and Scala are skipped for missing or mismatched toolchains.
…3064) From C's review of bc4703b. The early inference pass gives a name bound again in the same body the join of all its bindings (hoist_numeric_join), and asked for it at every numeric re-binding with a walk of the whole body, so a body bound n times cost n walks of it. A main with x = x + i 6000 times took 4.9 s to compile, 12000 times 24.7 s. The answer is not the same at every binding, though: a declaration's type is set once, the first time the walk types it, and a binding the walk has not reached yet has no type, so the join at a binding is that of the bindings typed so far (and of those written with a type). A join taken once per name and kept gives an earlier, narrower answer for the rest: g(c: int, u: uint32) { if c > 1 { x = 0 } else { x = 1 } if c > 0 { x = -1 } else { x = u } return x // a long; taken at the second binding, an int } and g's result settles as an int, which the checker then refuses ("narrowing return from 'g'"). HoistJoins (hoist.c) gathers a body's bindings, by name in the order join_walk meets them, with one walk of the body the first time the pass types a declaration in it. The pass reports each declaration it types (hoist_joins_typed), and a name's join is kept as the join of the bindings typed so far, in order, with those not reached yet that already have a number type (a written one), whose parts stand until the walk reaches them. So each answer is the one the walk of the body gave, and is taken again only when a binding it read is typed differently. A binding typed out of that order, or twice, makes every answer for its name a fold of that name's bindings alone. join_part and join_add are join_walk's step, shared. Measured, aetherc on one file under the memory cap, three runs each: 6000 re-bindings 4.9 s before, 0.12 s after; 3000 1.25 s and 0.08 s; 12000 24.7 s and 0.31 s. A main binding 3000 names in both branches of an if each went from 5.0 s to 1.6 s; what remains there grows with the square of the names too, and is the checker's and codegen's hoisting, which walk the body per hoisted name (hoist_if_branch_candidates, hoist_join_type), older than bc4703b. Checked: with every kept join compared against a walk of the body (hoist_numeric_join) at each ask, over every .ae file in std, contrib, examples and tests (2269, one at a time under the memory cap, AETHER_HOME the worktree), the 391375 answers in 1472 files are all the same. Test: tests/regression/test_issue3064_join_typed_late.ae, on both C paths: the program above and a local bound from it, each branch taken, a uint32 past an int's range. It passes on 0.838.0 too; a join taken once per name fails it, as above. The branch value, branch local, hoisting and clause set regression tests (17, both C paths), the integration tests hoisted_local_rebind, hoisted_local_scope, hoisted_local_zero_init, order_hoist_call_statement, sibling_binding_type and signed_unsigned_join, and make test (591) pass. The walk per binding came with bc4703b and shipped in 0.838.0; the fragment says so. On ed24f0b (0.838.0), with this machine shared, the 6000 re-bindings took 6.6 s before and 0.12 s after, three runs each. With the two commits after it, every .ae file in std, contrib, examples and tests and every ownership grid cell (2419, dumped from gen.ae) gives aetherc's exit status, diagnostics and C as on 0.838.0, but the new regression test of the third.
From C's review of bc4703b. A name first bound inside a branch or loop body is one variable for the whole function, with the join of its bindings, and a binding that wrote its type was joined like the others: if k > 0 { uint64 x = 7 } else { x = n } // refused if k > 0 { x = n } else { uint64 x = 7 } // an int; n = -1 printed -1 if k > 0 { x = -1 } else { uint64 x = 7 } // an int, -1 if k > 0 { x = 1.5 } else { int x = 0 } // a float if k > 0 { uint8 x = 7 } else { x = 300 } // an int, 300 hoist_numeric_join (hoist.c) joined the written type with the others and, for two no type holds, left the first binding's type, and the checker's sibling check (typechecker.c, the binding of a hoisted local) looked only at inferred bindings, joining a written local's type with each, so the written type was kept or dropped by the order of the two. A written type is the local's type now, and a value it does not hold is the error: - The join (JoinFold in hoist.c, for hoist_numeric_join, the early pass's HoistJoins and so codegen's hoisted declaration) is the first written number type when a binding writes one, whatever the others are. - declare_hoisted_local marks such a local (Symbol.hoist_written, from hoist_numeric_written). An inferred binding of it in another branch or loop body is not joined into it; a value it does not hold is refused there (report_unheld_binding): "cannot bind 'x' to this int value: it is written as uint64 in another branch or loop body of this function, which does not hold the value". ast_number_holds (ast.c) says what a written number type holds: an integer literal in its range (5 in a uint8, not 300 or -1 in a uint64), any other value that joins it to itself (an int in a long, a uint32 in a uint64, not an int in a uint64, a long in an int or a float in an int). - A binding that writes another type is refused: "cannot write 'x' as long: it is written as int in another branch or loop body". Both orders are reported at the binding the written type does not hold, once. The reference's paragraph on locals bound in branches says so. Test: tests/integration/written_type_sibling_branch: the five programs above, two written types, a match's arms and a loop body, each refused at its binding with the message, once; and a uint64 past a long's range, a long past an int's and a uint8, beside a literal or an int in either order, run whole. On 0.838.0 the first is refused with the old message and the others compile. The branch value, branch local, hoisting and clause set regression tests (17, both C paths), and the integration tests hoisted_local_rebind, hoisted_local_scope, hoisted_local_zero_init, order_hoist_call_statement, sibling_binding_type and signed_unsigned_join pass.
From C's review. A clause set with a clause that returns only `none`, one
that returns only `null` and one that returns a string was refused:
label(0) -> none
label(1) -> null
label(k) -> "k${k}" // the clauses of 'label' return string?
// (line 1) and `null`, which string? does not
// take
The early inference pass joins the clauses while the `none` one has no
type: `null` takes the string beside it and `none` makes that a
`string?`, and every clause that gives the set's result is then typed
`string?` (settle_clause_sets). The checker joined the same clauses again
from those types: clause_gives_only_none (ast.c) asked for a clause with
no type, so the `none` clause was a `string?` clause; and the string
clause, typed `string?` too, made the join a `string?` even with the
`none` clause known, beside which `null` was refused. So the two joins
of one set disagreed.
- A `none` clause is known by its returns, an inferred result every value
`return` of which gives `none`, as a `null` clause is, whatever type the
early pass gave it (clause_set_join skips it among the value clauses).
- `null` takes the optional of a pointer, string or closure type beside
it, as `return null` from a `-> string?` function gives the string `null`
is (some, not `none`): the checker's join of the settled clauses is the
early pass's again, `string?`, in any order of the clauses.
On the way: a set the join refuses was reported twice, once when the
checker settles the sets and again after a clause is checked
(typecheck_pending settles it again). TcClauses keeps that it was said.
That shipped in 0.837.0, and the refusal of the set above in 0.838.0, so
the fragment says both.
A `fn?` set takes the join too, but `return null` from a `-> fn?`
function is still refused ("'pick' returns closure?, but this value is
ptr"), the #3067 gap the review of 1954b8a reports; such a set is
refused there instead of at the join.
The reference's paragraph on clause sets says so.
Test: tests/regression/test_issue3063_clause_none_null.ae, on both C
paths: a string set with the `none` and `null` clauses first, one with
`null` first and `none` the catch-all, and a pointer set, each clause
called. On 0.838.0 each set is refused (3 errors).
tests/integration/clause_set_result_join gains none_null_int: `none`,
`null` and an int, refused naming the int's clause, once; on 0.838.0 it
is said twice. The clause set, #3056, #3061, #3063, #3064 and #3068
regression tests (17, both C paths), the integration tests
clause_set_import, clause_set_result_join, clause_set_scaling,
emit_lib_clause_sets, function_clause_string_pattern,
match_value_void_arm, missing_return, where_clause and
signed_unsigned_join, and make test (591) pass.
From C's review. A clause set of a `none` clause and one that only calls
the set again was never settled:
last(0) -> none
last(k) -> last(k - 1)
main() { println("${last(3) ?? -1}") } // `??` requires an optional
clause_set_join (ast.c) took `last(k)`, whose result is still unknown, for
a clause to be inferred and waited (CLAUSE_JOIN_WAIT). The early pass
gives such a set, waiting on itself, the join of the clauses known so far
(recursion_waits), and here there is none: the `none` clause has no type.
So the set was never settled, its calls kept the default int, and the
only error was the `??`, about no optional.
A clause whose result is inferred and every one of whose value `return`s
calls its own set (clause_only_recurses) gives the set's result and no
type of its own, as a `none` clause gives none: the join skips it, and it
takes the set's result (clause_takes_set_result). This set is then the
error a set of only `none` clauses is, CLAUSE_JOIN_NONE_ONLY, naming the
clause that calls the set: "every clause of 'last' that returns a value
returns `none`, an optional of no type, or calls 'last' again (line 2):
the set's result `T?` needs its `T`". Beside a clause that gives a type
(`count(1) -> 7`), the set is its optional, as it was through the
recursion's wait, and `gcd(a, 0) -> a` beside `gcd(a, b) -> gcd(b, a %
b)` is the first's type, now without waiting.
The reference's paragraph on clause sets says so.
Test: tests/integration/clause_set_result_join gains none_and_recursion
(the set above, refused with that message at line 1; on 0.838.0 only the
`??` is) and value_and_recursion (`none`, 7 and the recursion, run). The
clause set, #3056, #3061, #3063, #3064 and #3068 regression tests (17,
both C paths), the integration tests clause_set_import,
clause_set_scaling, emit_lib_clause_sets, function_clause_string_pattern,
match_value_void_arm, missing_return and where_clause, and make test
(591) pass.
Measured: every .ae file in std, contrib, examples and tests and every
ownership grid cell (2419, dumped from gen.ae), one at a time under the
memory cap with AETHER_HOME the worktree: aetherc's exit status,
diagnostics and C are those of 0.838.0 but for the new regression test
of the commit before.
…, and verified checksums First of the cross-language suite's fairness changes (#3076). The runner in benchmarks/cross-language/run_benchmarks.ae, rewritten in Aether as a table of the eleven languages: - The median is the median: the middle of the sorted runs (the mean of the two middle ones for an even count). It kept the run at index runs / 2 in the order the runs happened, so Aether ping-pong's 12.34, 12.29, 12.58, 12.09, 12.62 M msg/s reported 12.58. Min, max and the coefficient of variation (the sample standard deviation over the mean; it was range / mean * 50) come with it. - CPU time: the user + system time /usr/bin/time -v (or -l on macOS) reports for the whole process, per run, and a throughput per CPU-second beside the wall-clock throughput, in the JSON and on the page. A runtime that spins while it waits gets its wall-clock rate from cores it keeps busy; this column shows what each message cost. - The same warmup for every language (1 by default, BENCH_WARMUP). Only the JVM and BEAM languages had one. - Every language is built only through its own build file: `make <pattern>` in its directory, and the Makefiles call make, go build, cargo (the Cargo profile), zig build (build.zig), javac, erlc, ponyc or sbt. The runner passes no compiler flags; C++ was built with the runner's own g++ line, without the Makefile's -flto. New Makefiles for Go, Rust, Java, Elixir, Pony and Scala; Erlang's now builds all five patterns, and skynet runs without +P, which 1,111 processes do not need. Scala runs with `java -cp` and the classpath sbt exports, so its measurement no longer holds sbt's own JVM. - Every run's result is verified. Each implementation prints `Checksum:` (ping-pong: the sum of the values ping got back; counting: the count; thread ring: the receipts summed over the 100 nodes; fork-join: the sum of the values the workers received; skynet: the sum), and skynet its `concurrency units:`. A run whose checksum or unit count is wrong, that exits non-zero or prints no rate is [FAIL], listed under "failed" in the JSON and on the page, and not measured; a wrong Aether stops the run as a missing one did. - The toolchain version and the build flags (from each build file's `flags` target) are recorded in the JSON and shown on the page. - Memory and CPU time are n/a (null) where they cannot be measured: on Windows, MSYS's time sees only itself (0 s and about 5 MB for every native program), and anywhere /usr/bin/time is missing. - BENCH_LANGS and BENCH_PATTERNS select a subset. Making every result checkable changed some implementations: - The fork-join workers in C, C++, Go, Rust, Java, Elixir, Pony and Scala now add up the values they receive, as Aether's and Erlang's did: one addition per message everywhere (rule 3). Scala sent value i to every worker for round i; it sends the round-robin index, as the others do. Rust and Pony sent all N messages, not the equal share 8 * (N / 8) every other language sends. - The thread rings report every node's receipts. Scala's token went on round the ring after reaching 0, up to 99 messages more than its rate divided by; it stops at 0 now, as the others do, and divides by hops + 1. - A rate is always printed: skynet and Aether's patterns printed none when the run took under a microsecond, which a small smoke run does. Tests: benchmarks/cross-language/lib/benchstats holds the statistics, the parsing of a run's output and of GNU and BSD time, and the expected checksums; test_benchstats.ae checks them (the median of the #3076 runs, an even count, stddev and cv, fixed2, missing values as -1, the checksums and units), and tests/integration/bench_runner_stats runs it and type-checks the runner. It replaces tests/regression/ test_bench_cv_overflow.ae, which copied the old formula rather than testing the runner's. A pass with BENCHMARK_MESSAGES=10000 and two runs on Windows: Aether, C, C++, Go and Rust verify every checksum on all five patterns; Java, Erlang, Elixir, Pony and Scala are not installed here and are skipped (CI builds them), and Zig does not build with Zig 0.16 yet (the port is a later commit). make test: 591 passed.
…U, whole-program Second of the cross-language suite's fairness changes (#3076): build parity. With the runner building only through each language's build file (the previous commit), these are the settings each one uses. - C, C++ and Aether: -O3 -march=native -flto -fno-plt, in their Makefiles. C++ was built with the runner's own g++ line, without -flto; it now gets its Makefile's. - Rust: Cargo.toml's release profile already had lto = "fat" and codegen-units = 1; rust/.cargo/config.toml adds -C target-cpu=native, the -march=native the C column had and Rust did not. - Zig: build.zig's target defaults to the native CPU and the Makefile builds ReleaseFast; Zig compiles a program as one unit, so there is no LTO switch to add. Said in build.zig and the Makefile. - Go: `go build` is its release build (optimised, inlined, whole program). GOAMD64, Go's instruction-set level, is its nearest equivalent of -march=native and stays at the default; the timing commit records what raising it measures. - Java, Scala, Erlang and Elixir compile to bytecode that the JIT compiles for the host CPU, and every language now gets the same warmup run; Pony's ponyc builds a release for the host CPU by default. Their Makefiles say so, and each build file's `flags` target is what the results record.
ae3d is blocked on it. A program that defines a function of the name a
module it uses imports failed in that module since 0.837.0:
// main.ae // lib/pkg/user/module.ae
import pkg.user import pkg.frame
frame() -> ptr { ... } sets() -> int { return frame.SETS }
lib/pkg/user/module.ae:8:17: Type 'ptr' has no field 'SETS': a bare
`ptr` points at nothing the checker knows
The member access takes `ns.name` for a module's constant only when no
symbol of the name is in scope, so that a value of the name (a local, a
parameter, a global) is read as the value (module_name_local_shadow).
lookup_symbol finds functions too, and a module's merged code sees the
program's functions, so in pkg.user `frame` was the program's `frame()`
and `frame.SETS` a field of its `ptr` result. That was always so; what
changed in 0.837.0 is 979d6bc (#3048), which refuses a field of a bare
`ptr`, where before the access went through unchecked. The program's own
`frame.SETS` beside its `frame()` failed the same way.
A function has no fields, so it hides no namespace: value_hides_namespace
(typechecker.c) asks for a symbol that is no function and no module alias,
at the three places that ask whether a name hides a namespace (the
constant access in typecheck_expression, the export check in infer_type,
and the constant read in the value-type helper). A module's code reads its
own import's `frame.SETS`, and the program's does too; a local of the name
still hides the namespace.
The reference's section on local module imports says so.
Test: tests/integration/module_namespace_beside_program_fn, the issue's
layout: pkg.user reads frame.SETS and calls frame.make_eye and frame.width
beside the program's `frame()`, and main.ae does the same, then calls
`frame()`. On ca6b1f7 it fails with three of the errors above (two in
pkg.user, one in main.ae). The module scoping integration tests
(module_name_local_shadow, program_global_not_in_module_scope,
local_shadows_module_extern, builder_local_shadows_module_fn,
closure_param_shadows_module_fn, fn_param_shadows_consumer_fn,
self_shadowing_import, module_import_not_transitive, namespace_basic,
selective_import_shadow, module_leaf_collision, module_scoped_structs,
module_reexport, many_namespaces_qualified_call and
transitive_module_import) and make test (591) pass.
…e work Third of the cross-language suite's fairness changes (#3076): a review of all eleven languages times five patterns, Aether's own included. The rule, fixed per language from the shape of each pattern before measuring, and the same code in every pattern of a language: - C, C++, Rust and Zig: every mailbox here has one sender and one receiver, so each uses one ring for that shape, built on its standard library's atomics: a sequence number in each slot (Vyukov's bounded queue for one sender and one receiver), so neither side reads the other's index and a message moves one cache line. Where the units fit the cores (ping-pong, counting, fork-join) a receiver spins up to 1000 pauses, looking again after 1, 2, 4, ... 64 of them, then sleeps; where they outnumber the cores (the 100-thread ring) it sleeps at once, and skynet's 1,111 threads join their children, which blocks at once. C and C++ waited on a mutex and a condition variable for every message; Rust used std's mpsc channels; Zig did not build with Zig 0.16 at all (std.Thread.Mutex, std.posix.getenv and GeneralPurposeAllocator are gone) and is ported to std.Io. - Java and Scala: platform threads with LinkedTransferQueue, which spins briefly before it parks, where the units fit the cores; virtual threads with ArrayBlockingQueue, which parks at once, for the ring; ForkJoinPool tasks for skynet, as before. Every queue was ArrayBlockingQueue on a platform thread. - Go, Erlang, Elixir, Pony and Aether: the runtime's own units and mailboxes, which have no such choice; each runtime spins before it parks. Measured on Windows (i7-13700K, 24 logical cores, the editor's indexer keeping about one busy), 1M messages, median of 5 runs and range, M msg/s: ping-pong counting thread ring fork-join C 0.20 -> 7.50 15.09 -> 57.23 0.38 -> 0.38 0.81 -> 33.62 C++ 0.20 -> 7.21 17.90 -> 94.11 0.36 -> 0.38 0.79 -> 32.37 Rust 2.02 -> 7.77 23.00 -> 54.65 0.34 -> 0.34 1.24 -> 35.52 Zig none -> 7.34 none -> 58.09 none -> 0.42 none -> 34.57 Java 0.19 -> 3.37 9.31 -> 9.51 0.36 -> 1.74 0.82 -> 10.60 (Before: the first batch of runs; after: the third, with the old Java and Rust's and Zig's std channels in the same rounds as anchors. C's CPU time per ping-pong run fell from 3.73 s to 0.27 s and per fork-join run from 2.31 s to 0.28 s: the old code paid a sleep and a wake-up per message.) Every alternative measured is listed in FAIRNESS.md: the spin bounds (100, 1000, 10000), C++20 atomic wait (slower than the condition variable in every pattern), the index ring with and without backoff, Rust's channel and sync_channel (sync_channel leads counting at 92, and falls to 2.3 in fork-join), Zig's std.Io.Queue (0.21 in ping-pong), Java's ArrayBlockingQueue, LinkedBlockingQueue and virtual threads everywhere, Go unbuffered and with 16k-slot buffers. Also, found in the review: - The clock starts before the units are created in every pattern (rule 5): counting, thread ring and fork-join created them first in Aether, C, C++, Go, Java, Zig, Erlang, Elixir and Pony. - Java and Scala printed their rates in the default locale: "2,38" where a locale uses a decimal comma, which the runner reads as no rate. They print with Locale.ROOT. - Aether's fork-join picked the worker with a division and eight comparisons per message; it indexes an array with i % 8 like the others. Aether's skynet sent every unit a Setup message with its own ref before its work, two messages per unit where the others send one; a node passes `self` as the parent now. - Elixir's counting sent with Enum.each, an anonymous function call per message; it is a recursive loop, as in Erlang and in its own fork-join. - Rust's skynet gathered its children's sums over an mpsc channel; it joins them, as C, C++ and Zig do (0.04 M units/s either way). - Zig's skynet joined an undefined thread when a spawn failed; a failed spawn is a failed run now. Zig prints to stdout. - Java's and Scala's skynet printed no rate under a microsecond. Java's thread ring had 8-message mailboxes for its one token; 1 now, as in Scala and everywhere else. - Go keeps buffered channels of 1 and 1024 slots (unbuffered and 16k measured the same). Java's and Scala's choices were measured with a portable Temurin 21; Scala follows Java's, which it calls through the same java.util.concurrent. Erlang, Elixir and Pony are not installed here: their changes are the timed region and Elixir's send loop, reviewed by reading, and the CI job of a later commit builds and runs them. Aether's thread ring and fork-join measured bimodal on this machine (fork-join 1.53 to 21.51 M msg/s within five runs), whatever the benchmark code: aether#3087. make test: 591 passed.
…native measured Fourth of the cross-language suite's fairness changes (#3076), the written record of the first three. FAIRNESS.md keeps its six rules and adds five: each language built by its own build file at its release level for the host CPU (7), every result checked (8), the same warmup for every language (9), the technique an expert would use, fixed by the shape of the work and the same in every pattern of a language, never picked pattern by pattern from the results (10), and each implementation's wait strategy written down beside the CPU time it costs (11). A techniques table gives each language's units, mailbox and way of waiting, and the rule behind its choice. "Audit, October 2026" lists each finding with its measured effect: the median that was the middle run in time order (Aether ping-pong's runs reported 12.58 where the median is 12.34); the build flags that differed, which made no difference five runs can see (C++ without -flto, Rust without target-cpu=native, Go's GOAMD64=v3: all inside each other's ranges); the warmup only the JVM and BEAM got; Scala measured under sbt; the errors the checksums found (Scala's fork-join values and its ring running past 0, Rust and Pony sending N fork-join messages); the wait strategies, with a table per language of every alternative measured (C and C++ went from 0.20 to 7.50 and 7.21 M round trips a second in ping-pong and from 0.81 to 33.62 and 32.37 M msg/s in fork-join); what reading the code found (the timed regions, Aether's fork-join and skynet doing work no other language did, Elixir's send loop, Java's and Scala's locale-dependent rates); Zig not building with 0.16; and Aether's thread ring and fork-join running bimodal on this machine (aether#3087), which leaves its own before and after there unreadable. It ends with where the numbers stand, with each run's CPU time. The measurements: an i7-13700K with 24 logical cores, the editor's indexer keeping about one busy, 1M messages, one warmup and five interleaved runs. The suite's README describes the methodology as it is now (median with min, max and CV, the same warmup for all, CPU time per run and messages per CPU-second, build files, checksums, the technique rule) and each language's concurrency model; docs/performance-benchmarks.md did not (Scala on Akka, "total tree nodes", a warmup for JIT languages only, a CV of range * 50), and follows it. config.md described a Python step and a benchmark_config.json that nothing has read since the runner moved to Aether; it lists each language's toolchain and build file and the runner's environment settings now, and the unused JSON file is gone.
…every week Last of the cross-language suite's fairness changes (#3076). Nothing built the suite in CI, so a language could break unseen: Zig had not compiled since Zig 0.16, and a compiler or runtime change once turned every Aether entry into a skip while the other ten kept reporting. .github/workflows/benchmarks.yml, one job on ubuntu-24.04, kept cheap: - Smoke, on a pull request or push that changes benchmarks/cross-language/ (or the workflow), and every Monday: installs the eleven toolchains (apt for C, C++ and GNU time; setup-go, setup-java with Temurin 21, setup-sbt, erlef/setup-beam for Erlang and Elixir, setup-zig for 0.16, ponyup for Pony; Rust is on the image), builds Aether, and runs every language on every pattern once at 10,000 messages. The runner checks each checksum and skynet's unit count. The weekly run catches what no path filter cheaply does: breaks from outside the directory. - Full, by hand (workflow_dispatch): the whole suite at 1,000,000 messages and five runs (both inputs), the results JSON uploaded. - When a run fails, the job summary names each language and pattern that broke and why; the results, build logs and that list are uploaded. The runner gets BENCH_STRICT=1: a selected language that does not build, or whose result is wrong, makes it exit 1 (without it a missing toolchain stays a skip, as on a developer's machine). Every run writes what broke, one "<language> <pattern>: <reason>" per line, to build/problems.txt, which the job's summary step reads. `make benchmark-results` runs the suite without the dashboard, and `make benchmark` is it plus the dashboard. FAIRNESS.md and config.md describe the job and BENCH_STRICT. Checked here: BENCH_STRICT=1 with Pony selected and not installed exits 1 and lists "Pony ping_pong: did not build (build/pony_ping_pong.log)", the same run without it exits 0; a run of every pattern at 10,000 messages verifies Aether, C, C++, Go, Rust, Java and Zig. tests/integration/bench_runner_stats passes; make test: 591 passed.
nicolas-maman
force-pushed
the
fix/oct21-batch5
branch
from
October 21, 2026 20:48
2a0a98d to
f720aa6
Compare
This was referenced Oct 21, 2026
From the review of #3067. `null` is the empty closure where a value goes into a closure slot (#3062, #3067), but these slots never gave it the closure's type, so it reached C as `NULL` or the checker refused it: let o: fn? = null // _aether_closure_retain(NULL) o = null; s.cb = null // a fn? local and field, re-bound Slot { cb: null }; take(null) // a fn? field and parameter nothing() -> fn? { return null } // returns closure?, but this is ptr [held, null] // held: fn?, "cannot hold" let two: fn[2] = [null, null] // all null: an array of ptr let t: (fn, int) = (null, 1); first((null, 2)) hook = null // var hook: fn?, in a function o ?? null // type mismatch in conditional o == null // `.val == NULL` on the closure and `o == q` between two `fn?`, or a `fn?` and a closure, passed the checker and reached C as `==` on the closures they hold. - slice_coerce_slot (slice_coerce.c) gives `null` the empty closure in an optional of a closure, as a `ptr?` given `null` holds a null pointer, and goes into an array literal of closures or `fn?` element by element and into a tuple literal part by part, each by its own slot's rule. - ast_type_takes_null (ast.c) takes a `fn?`, so `return null` from `-> fn?` and a `null` element beside `fn?` ones fit, as they do beside closures. - The `==` refusal (typecheck_binary_expression) takes a `fn?` operand for the closure it holds; against `null` and `none` it is allowed. - `hook = null`, the re-bind of a module-level closure or `fn?` var from a function, and the default of `??`, a slot of the value type, go through slice_coerce_slot. - Codegen: `o == null` on a `fn?` is `o == some(null)`: present, holding the empty closure (`has && val.fn == 0`). A `fn?` given `null` is not `none`, and `o == null` is true. The reference's closure section and its paragraph on what a `return` converts say so. Test: tests/regression/test_issue3077_closure_null_slots.ae, on both C paths: every slot above, the multi-value return and the module var initializers beside them; on ca6b1f7 it is refused (2 errors) and, without those two shapes, fails in C. tests/integration/ closure_compare_null gains two `fn?`, a `fn?` and a closure (refused), and a `fn?` against `none` and `null` (run); on ca6b1f7 the first is accepted and the last fails in C. 20 regression tests on closures, optionals, tuples and `??` (both C paths), the integration tests array_literal_element_type, closure_local_env_free, closure_store_through_fn_coercion_no_uaf, fn_type_modes, fn_typed_local_call, optional_compare_value, optional_field_access, optional_owning_fields, keeps_borrowed, into_param and closure_args_borrowed, and make test (591) pass. Measured: every .ae file in std, contrib, examples and tests and every ownership grid cell (2419, dumped from gen.ae), one at a time under the memory cap with AETHER_HOME the worktree, against 4811e95e: aetherc's exit status, diagnostics and C are the same for every file but the new regression test.
A function returning `fn?` hands its caller the closure in it (R3). Bound
to a local, the local owned it and gave it back at its scope's end;
anywhere nothing bound the optional, nothing did:
maybe(k) != none // (maybe(k)).has
maybe(k) == null // _ae_ol = maybe(k), ...val.fn == 0
match maybe(k) { some(c) -> ... none -> ... }
c = maybe(k)! // _aether_closure_retain(... .val)
c = maybe(k) ?? null // the same
maybe(k) // as a statement
each leaked the closure's environment at every call, 16 bytes for one that
captures an int (1600 per 100 calls on a5db8413, each shape).
call_returns_owned_opt_closure (codegen_stmt.c) is the `fn?` counterpart
of call_returns_owned_closure: a call of anything but a C function, whose
result is a `fn?`, hands its closure over. The expression that reads such
a temporary gives the closure back:
- `== none`, `!= none` and `== null` read the flag (and the function
pointer) from a temporary, then release the environment it holds, as an
owned `string?` call's string is freed there (#2750);
- a `match` on the call: the `some` binding, or a hidden one under `_`,
owns the closure for the arm, a composite local whose every exit drops
it (push_own_drop), as a local bound to a fresh closure; an arm that
returns or stores the binding hands it on as from such a local;
- `call!` and `call ?? d` give a fresh closure (is_owned_closure_unwrap,
closure_value_is_fresh), so the place that takes it adopts it instead of
retaining; the `??` default is taken as one of its own (adopted when
fresh, retained otherwise, emit_closure_take);
- the call as a statement releases it, as a discarded closure is (#2507).
Test: tests/regression/test_issue3078_opt_closure_temp_release.ae, on both
C paths: each shape in a loop, an arm that returns from the function
through its binding, `??` with a closure default, checked by value and by
mem.steady_growth over rounds; on a5db8413 it fails (2240 bytes per
round). A match arm that returns its binding, and one that stores it in a
module var, run right with no growth (2550, 0 bytes; a5db8413 grows 1600).
The closure and optional regression tests (89, both C paths), the
integration tests closure_local_env_free, closure_args_borrowed,
closure_store_through_fn_coercion_no_uaf, keeps_borrowed,
optional_owning_fields, optional_compare_value, closure_compare_null,
array_literal_element_type, fn_type_modes and into_param, and make test
(591) pass.
Measured: every .ae file in std, contrib, examples and tests and every
ownership grid cell (2419), one at a time under the memory cap with
AETHER_HOME the worktree, against a5db8413: aetherc's exit status,
diagnostics and C are the same for every file but the new regression
test, so no grid cell changes.
nicolas-maman
force-pushed
the
fix/oct21-batch5
branch
from
October 21, 2026 21:35
1fdfcf9 to
bf67e08
Compare
…m's (#3088) The root cause under #3084, from C's review of it. A module's merged code resolved names the program declared: // main.ae // lib/pkg/user/module.ae import pkg.user leak() -> int { return helper() } helper() -> int { return 42 } // printed 42: never imported and a program's `struct frame { w: int }` broke pkg.user's `frame.SETS` as its `frame()` did (#3084). lookup_symbol_in (typechecker.c) kept a module's body out of the program's top-level `var`s and of the names its imports bind, but not out of its functions and types. - A merged body (merged_from) no longer resolves a file-scope symbol that is the program's own declaration (is_program_own_decl): a function, a builder, a struct, a sum, an enum, a bitstruct, an actor, a message or a constant written in the program's files, not merged from a module. Its own declarations are merged under its prefix, its imports' names were renamed to theirs, and builtins have no declaration, so what it sees is its own scope; a call of a program's function is "Undefined function". Beneath a program's declaration it finds what is its own: an older symbol of the name that is no program declaration, or else its own extern (std.io's `string_concat` beside a program function of that name, libaether_symbol_collision). A name the module declares itself stays its own: a struct identical to the program's is merged as that one type (#2129), and the module names it as its own (tls13_client's RecordIO when tls13_server is checked on its own). - A function @derive makes for a module's struct is declared in that module (origin_module), so the module's body reaches it (derive_schema_cross_module's `Health_schema()`). - The older shape the issue names: a module's own function named like its import was renamed into the base of `frame.SETS` by the merger (#235, rename_intra_module_refs, aether_module.c), `user_frame.SETS`. The base of `ns.member` is left alone when `ns` is a namespace the module imports and a function of the module's own, not a constant, a var or a local: a function has no fields, as the checker reads it. value_hides_namespace (#3084) stays: within the program itself, a function of an import's name (`frame()` beside `import pkg.frame`) still must not hide the namespace in `frame.SETS`. The reference's section on local module imports says so. Test: tests/integration/module_body_own_scope: a program's struct of the import's name beside pkg.user's `frame.SETS` and `frame.twice(1)` (prints "5 4"), pkg.user's own `frame()` beside its import (prints "13"), and pkg.user calling a program's `helper()`, refused at the call. On ca6b1f7 all three fail. The module, namespace, import, shadowing, derive and symbol collision integration tests (83, 2 skip with no java and ruby here) and make test (591) pass. Measured: every .ae file in std, contrib, examples and tests and every ownership grid cell (2419), one at a time under the memory cap with AETHER_HOME the worktree, against 6d1eb706: aetherc's exit status, diagnostics and C are the same for every file (one line that differed was a run cut short by a pause, the same three times over on its own).
…3070) From C's review of 999acab (#3070). A local bound in several branches took its written type only when that type was a number: if k > 0 { x = "s" } else { int x = 1 } // crashed when k <= 0 compiled with `x` the string's `const char*`, and the int branch stored `aether_uniform_heap_str((const char*)(1))`; the other order was refused. join_part and fold_add (hoist.c) leave out a part that is no number, so the written int never made the join, never made the local hoist_written, and hoist_join_type kept the first binding's type. - hoist_written_type (hoist.c, in place of hoist_numeric_written) gives the type the first binding that writes one writes, of any kind, and hoist_join_type returns it whatever the others are: a written type is the local's type, for codegen's hoisted declaration and the checker's. - report_unheld_binding (typechecker.c) refuses a value the written type does not hold, a number by ast_number_holds, any other value when it is not assignable to it: "cannot bind 'x' to this string value: it is written as int in another branch or loop body of this function", in either order. The reference's paragraph on locals bound in branches says so. Test: tests/integration/written_type_sibling_branch gains the program above in both orders, refused at the string binding. On b76c298a it runs and crashes (access violation) in the first order. The written type integration tests, the branch, hoist and clause regression tests (14, both C paths), hoisted_local_rebind, hoisted_local_scope, hoisted_local_zero_init, sibling_binding_type, signed_unsigned_join and order_hoist_call_statement, and make test (591) pass. Measured: every .ae file in std, contrib, examples and tests and every ownership grid cell (2419), one at a time under the memory cap with AETHER_HOME the worktree, against 5b55aa94: aetherc's exit status, diagnostics and C are the same for every file (a grid cell that differed once compiled the same three times over on its own).
…kept The first smoke run of the benchmarks workflow (on 1fdfcf9) failed Scala on all five patterns: "run 1 exited with status 1". Its Makefile wrote classpath.txt as the last line of `sbt -batch -error "export Runtime / fullClasspath"`. On CI sbt ends its output with a terminal escape (ESC[0J) and no newline (the build log shows it after `compile`), so that last line was the escape, and `java -cp "$(cat classpath.txt)"` found no class. - scala/build.sbt: a writeClasspath task writes the runtime classpath (the compiled classes and the Scala library) to classpath.txt with IO.write, joined by the platform's path separator. The Makefile runs it once (`sbt -batch -error writeClasspath`, which compiles first), so nothing sbt prints is parsed. - run_benchmarks.ae: a failed run's output goes to build/<language>_<pattern>_run.log, which the workflow already uploads (build/*.log), and the reason names it. This failure said only that the run exited 1; the next one will say why. sbt is not installed here, so the Scala side is checked by the workflow on this push. The runner change was built and run with a forced failure: "run 1 exited with status 3 (build/c_ping_pong_run.log)", the log holding what the command printed.
… threads on their CPUs by a hint (#3087, #3095) Fork-join, thread ring and skynet ran bimodal on Windows (i7-13700K, 24 logical cores): fork-join 1.53 to 21.51 M msg/s in five runs. Counters put in the scheduler for the diagnosis (parks, park timeouts, timeouts that found work waiting, wake signals, the CPU main ran on) found two causes. Lost wakeups. A core that has spun idle parks on a condition variable with a timed wait, and every enqueue onto it calls sched_wake to signal it. Two enqueue paths did not: - scheduler_send_batch_flush, which every `while` loop of sends in main() goes through (the codegen batches them): it enqueued onto each core and never told a parked one. A fork-join's workers, parked between two batches, slept until their timed waits ran out: in five runs, 8, 16, 131, 134 and 8 timeouts found work waiting, the slow runs the ones with main on a pinned worker's CPU, and no signal was sent in any of them. - overflow_flush's queue drain onto another core. Both wake the core now. And the wake itself could be missed: the sleeper stores `parked` then reads its queues, the sender stores its message then reads `parked`, each a store and a load the CPU may reorder (x86 from its store buffer) under release and acquire alone. A sequentially consistent fence on each side, as the actor-thread park already has, means one of them sees the other. A missed wake cost up to PARK_MS_MAX (32 ms), and on Windows, whose timed waits end on its 15.6 ms tick, at least that. Hard pinning on Windows. pin_to_core bound scheduler thread i to logical CPU i with SetThreadAffinityMask (16 threads on CPUs 0 to 15, the P-cores' hyperthreads). A thread bound to one CPU cannot leave it when another program's thread runs there (the editor's indexer, a browser) and shares it a time slice at a time, so a ring whose path crossed such a CPU ran at a fraction of its speed, and a run fell into a fast or a slow mode by where the other load landed: at 200,000 hops, pinned 0.52 to 0.80 M msg/s and unpinned 2.24 to 2.38, with parking ruled out (no parks at all measured the same). A thread now gets an ideal processor (SetThreadIdealProcessor), a hint: Windows keeps it there and moves it when the CPU is busy, as macOS's affinity tag lets it. Linux keeps its hard binding; whether it has the same trouble under background load is its own issue (#3089). Measured, 1,000,000 messages, 1 warmup and 10 interleaved runs, the editor's indexer and a browser keeping about 1.5 cores of 24 busy; M msg/s, median (range), coefficient of variation: base wake fixes, pinned wake fixes + hint fork-join 5.04 (1.05-21.81) 98.7% 27.06 (24.60-28.29) 4.6% 30.38 (24.83-31.89) 6.8% thread ring 3.02 (2.08-3.16) 14.9% 3.08 (2.48-3.35) 9.2% 2.85 (2.53-3.06) 6.4% skynet 1.66 (0.31-2.10) 47.7% 1.60 (0.89-1.95) 23.0% 1.78 (1.22-1.96) 14.8% ping-pong 11.83 (8.79-12.07) 9.2% 11.95 (10.21-12.34) 5.1% 12.29 (12.00-12.55) 1.6% counting 42.54 (41.88-42.74) 0.7% 42.47 (40.93-42.86) 1.4% 42.67 (42.19-43.01) 0.6% Unpinned measured the same as the hint (fork-join 29.65, ring 2.97, skynet 1.60, ping-pong 12.39). The ring stayed in its fast mode in all of this window's runs, base included: its slow mode needs other load on a pinned CPU of its path, which the 200,000-hop runs above caught. Skynet at a million leaves is about half a millisecond of Aether's time, where a single wake or time slice moves the rate; its spread narrows, not to ping-pong's. The fences cost nothing measurable: ping-pong, all cross-core sends, is at least as fast. Counting runs in main-thread mode and is the control. Test: tests/runtime/test_scheduler.c, "Scheduler batch send wakes parked cores", a test of the mechanism, not of wall time. A first version timed ten rounds of park-then-batch in an .ae program against 60 ms; CI runners missed that bound with the fix in (63 ms on MSYS2, 205 on UCRT64, 1973 on macOS x86_64), as a busy machine misses any bound a missed wake would cost. Now two cores park with a timed wait of a minute (scheduler_set_core_park_ms, a test setting beside scheduler_set_actor_park_ms), and each of three rounds waits until both are parked, then sends one message from the batch path to each of four actors, two a core, and waits up to 10 s for each to be stepped. Fixed, the test takes about 60 ms; with the flush's wake removed it fails every time (3 of 3), no actor stepped until the minute ran out. Writing it found #3095, fixed here: aether_park_deadline computed the deadline as (long)ms * 1000000L, and `long` is 32 bits on Windows, so a wait past 2147 ms overflowed. A minute came out 0.13 s in the past (the wait returned at once) or, with a negative tv_nsec, weeks ahead: the test passed with the wake removed in four runs of five and hung in the fifth, and the actor-thread park test's minute had never been one on Windows. It is computed in 64 bits now. The default parks, 1 to 32 ms, were never affected. Shutdown on macOS. 1fdfcf9's macOS leaks gate [1/2] hit its 25-minute cap with a program and a leaks process left behind, which read as a program that never exited. No program hung: - The one left was test_std_mem_bulk_endian, which has no actors: the compiler emits scheduler_init and the rest only for a program that has them, so no scheduler thread ever ran in it. It had been started 2.6 s before the cap (test_std_bytes_get passed at 21:20:07.6, the step was stopped at 21:20:10.2). - Every program runs under a 120 s bound that kills its process group and reports a timeout; neither shard reported one, and all 272 programs the shard reached passed. - The shard was slow, not stuck: from 66 s into the gate to about 1320 s, every program took 6 to 18 s instead of 3 to 4, those without actors as much as those with them (test_int64_type_name 2.7 s on 54147c6, 13.8 s here), and the last two minutes ran at the old pace again. The [2/2] shard of the same run had such a window from 600 to 900 s; the six leaks jobs of the three runs before it had none. The step that builds the compiler took the same time as on 54147c6 (59.5 and 58.8 s). In the same run the macOS ARM64 test job never got a runner and was cancelled after 30 minutes. So the macOS ARM64 machines were loaded that hour. Nor can the park and wake changes hang a shutdown. Every park is a timed wait, at most 32 ms. scheduler_stop stores running = 0, then broadcasts on each core's park_cond under park_mutex; a core parks only after reading running under that mutex, so it either sees 0 and does not wait, or is already waiting when the broadcast comes. sched_wake takes one lock, the target's park_mutex, and no caller holds a lock the parked side wants (it holds park_mutex alone and only reads its queues), so the new calls from the batch flush and overflow_flush add no lock order. The fences change only ordering. The 34 actor, ask, scheduler and cooperative tests in tests/regression and tests/syntax and make test (592) pass. The POSIX side compiles with -Wall -Wextra and no warning (-fsyntax-only, MSYS2's gcc).
…3077) `t = (null, 1)` then `a, b = t` failed in the C compiler with "unknown type name '_tuple_ptr_int'", and so did `(p, 2)` with `p` a `ptr` and `(1.5, true)`. A tuple type no signature names was given its typedef where its destructure was generated, which is after the local's declaration names it. A tuple that owns a string was spared only because the composite pass (emit_own_ops) had declared its type already. collect_tuple_typedefs walks the program before the function bodies, as collect_optional_typedefs does for optionals, and declares every tuple type a node has whose parts C can spell (tuple_parts_known: none unknown or void, nested tuples included). ensure_tuple_typedef keeps each one declared once, so the later calls find it. Corpus and ownership grid sweep (every .ae file and the 2419 grid cells, aetherc before and after): no status or diagnostic changes; the C changes for three tests whose tuple typedefs moved up (test_closure_tuple_result, test_issue3077_closure_null_slots, test_sum_owns_variants), each of which passes on both C paths. Test: tests/regression/test_issue3077_untyped_tuple_local.ae fails before ("unknown type name" and "invalid initializer") and passes after, on the default and the AETHER_GCC_COMPAT=0 C paths.
…vent loop and test clocks (#3096) A `long` is 32 bits on Windows (and on 32-bit targets), and clock readings, stored times, duration sums and counters were kept in one. Found while fixing #3095, the same overflow in the scheduler's park deadline. The whole tree was searched for the pattern ((long) times 1000 or 1000000, long-typed ms/us/ns values and sums, GetTickCount64 or tv_sec products into a long). Every instance is fixed here. What broke, and what did not. The clocks are CLOCK_MONOTONIC, which on Windows is time since boot. `(long)tv_sec * 1000...` overflows once uptime passes the limit: undefined behaviour that GCC wraps, so the difference of two readings mostly still came out right. The visible failures are comparisons of absolute readings, durations longer than a long holds, and sums: - std/http/proxy: aether_proxy_now_ms returned (long)http_clock_ms(), so the reading went negative after 24.8 days of uptime and stayed so for as long again. In that time the event loop read every request deadline as none (`deadline_ms <= 0`), and upstream timeouts stopped firing. The cache compared expires_at_ms <= now across the wrap. It returns int64_t now, as do the breaker's open time, the per-upstream rate limit's refill time, the cache entry's stored and expiry times, the event loop's deadlines, and the elapsed times passed in. - aether_proxy_cache.c: expires_at_ms = stored_at_ms + (long)ttl_sec * 1000L overflowed for a TTL past 24.8 days, and the entry expired as it was stored. - aether_http_server.c: the metrics' request-duration sum overflowed after 35.8 minutes of summed request time (minutes of traffic on a busy server) and the counters after 2^31. The proxy's latency sum overflowed after 24.8 days of summed latency (hours on a busy proxy), and its counters after 2^31. All are _Atomic int64_t now, printed with %lld. - http_now_us: a request longer than 35.8 minutes (a long stream) got a wrapped duration. HttpRequestHook takes int64_t duration_us. It is a C-only extension point, not callable from Aether (docs/http-server.md now gives its signature). The access log's byte count is long long. - aether_middleware.c monotonic_ms (rate limiter): a bucket idle for more than 24.8 days read a negative elapsed time and did not refill. Same root cause, not time: - aether_http_evloop.c parsed Content-Length with strtol into a long. A length past 2 GiB saturated to 2147483647 and the body was read short of what the client declared. It is strtoll now, with an ERANGE check, as the client side's parse. - aether_proxy_direct_take compared (long)body_len against the cap. Tests and tools: test_harness.c's get_time_ms returned GetTickCount64() as a long, and the watchdog's start time was an atomic_long. The clocks in test_scheduler.c, test_scheduler_stress.c and bench_zerocopy.c also read into a long. contrib/avcodec's pts_ms was a (long) of a double. Its API still returns an int. Test: tests/runtime/test_http_server.c, http_proxy_cache_keeps_a_long_ttl stores a response under a 30-day default TTL and looks it up. Fixed, the entry is found with its full 30 days ahead. With the old expiry math put back, it fails on Windows ("found"). The clock and sum fixes have no practical test: they need weeks of uptime or minutes of summed traffic. Checked: make test (593) passes. The POSIX side compiles with -Wall -Wextra and no warning (MSYS2 gcc -fsyntax-only, 16 files). The http_server_observability (access log JSON and Prometheus metrics) and http_middleware_d1 (rate limit) integration tests were run on Windows with their Windows skip removed, and both pass. http_reverse_proxy_pool and _extra (cache, retry, rate limit, trace, metrics) pass in their Windows-reduced form. contrib/avcodec is not built here (no FFmpeg).
This was referenced Oct 21, 2026
nicolas-maman
marked this pull request as ready for review
October 21, 2026 22:31
This was referenced Oct 21, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Every open bug, batch 5, after #3066 and v0.838.0, and a fairness overhaul of the cross-language benchmark suite. One commit per issue, each with a test that fails before it, the docs it makes stale updated in the same commit, and a changelog fragment; every memory and ownership fix follows the ownership model's R1 to R4. Draft until every issue below is on this branch and a review round comes back clean.
Every open bug, and where it stands
x = -1besideuint64 x = 7was an int)intcompiled and crashed; a written type of any kind is now the variable'snullin afn?slot,fn? == fn?and anulltuple part pass the checker and fail in C (follow-up of #3067)nullis the empty closure in every closure slot, an optional's included;fn? == fn?refused like two closures. Follow-up on the branch: an unannotated tuple local that owns nothing ((null, 1),(1.5, true)) had its C type declared after its use; every tuple type is now declared aheadfn?returned by a call leaks its closure's environment when the caller only tests itfn?a call returns is given back when nothing binds it (six shapes, 1600 bytes per 100 calls to 0)@keep fnis decided by function names (a qualifiedcollections.list_addmissed, a program's ownlist_addrefused)ae fmtrewritesmake([]fn, 2)asmake([] fn, 2)make benchmarkcould not run: the Aether entries never linked, the median was the middle run, Linux memory read 0, Windows could not run itns.memberwhen it importsnsnullfn, or afn?holding null) crashes with no messagearr.lengthpasses the checker and fails in Clong(64-bit) defined with a 32-bit Windowslong(Lua integers truncated)fs[1]()is refused as "Undefined function '?'"Benchmark fairness (#3076)
The goal: each language measured at what its own standard library provides, written with the techniques an expert would use, no frameworks, so the suite adds no unfairness of its own.
LinkedTransferQueue, virtual threads for the ring. Ping-pong went from 0.20 to 7.50 M msg/s for C, fork-join from 0.81 to 33.62; Java's ping-pong from 0.19 to 3.37. Every alternative measured is listed. Zig ported to 0.16; Java and Scala printed rates in the machine's locale and the runner read none.Then one review round of the whole branch and a full local gate (build,
make test, strict C11 sweep, check-docs, full sweep, ownership grid) on the final head.Closes #3070
Closes #3084
Closes #3076
Closes #3077
Closes #3078
Closes #3088
Closes #3087
Closes #3095
Closes #3096