Summary
Every assignment in every EigenScript program computes the entropy of the
assigned value, whether or not anything ever observes that binding. On a real
consumer workload that uses zero observer features, this is 88% of
runtime — a measured 8.50x ceiling.
src/compiler.c:1804 emits the observe opcode unconditionally:
emit_op_u16(c, obs_op, obs_arg, line); /* every assignment, always */
emit_op_u16(c, set_op, set_arg, line);
vm.c:4630 (OBSERVE_ASSIGN_LOCAL) then calls observer_slot_update
(eigenscript.c:547) → compute_entropy (eigenscript.c:339), which for a
list walks every element via ent_child (eigenscript.c:331), each doing a
log2. Assigning a list of length n costs O(n) log2 calls.
In a CDCL solver, watch-list assignment is the hottest loop in the program.
Measurement
Workload: EigenMiniSat Tseitin torus 4x4-odd, current CDCL defaults, v0.39.0,
devbox (Celeron N3350). Real workload, not a proxy. The solver contains no
observe/report/predicate usage anywhere (grep count: 0 files).
perf record -F 199, self time:
| symbol |
self |
ent_child |
46.46% |
__log2_sse2 (libm) |
21.92% |
entropy_of_num |
10.05% |
compute_entropy_impl |
3.32% |
| entropy subtotal |
81.75% |
vm_run_ex (interpreter dispatch) |
4.21% |
gc_collect_impl |
1.91% |
jit_helper_index_get |
1.46% |
_int_malloc + malloc_consolidate |
1.65% |
Ceiling probe
Semantics-breaking, timing-only: early-return from observer_slot_update and
observer_slot_update_num behind an env var, so one byte-identical binary
serves both arms. (Patch was reverted; tree is clean.)
| arm |
4x4 wall |
| baseline |
299.95 s |
| observer disabled |
35.28 s (n=3, spread 0.5%) |
|
8.50x |
Counters byte-identical across both arms (conflicts=9986 resolutions=33873),
confirming the probe changed only observer bookkeeping.
External reference
Native MiniSat 2.2.1 on the byte-identical CNF, same box: 0.25 s (and it
uses 84,150 conflicts to our 9,986 — our search is better, our throughput is
not). Gating the observer moves us from ~1,200x native to ~141x.
Why this matters beyond one consumer
-
The JIT is a net 3.0% LOSS on this workload (299.95 s on / 290.83 s off,
counters identical). Now explained: it cannot win, because only 4.21% of time
is dispatch. It pays compile cost against a 4% slice. scanned=62 compiled=56, so it is compiling fine — there is just nothing there to get.
-
This is a flag for the AOT performance case. AOT is positioned as the
strategic native-performance path, but it eliminates interpreter dispatch,
and dispatch is 4.21% here. Unless AOT also elides observer updates, its
ceiling on this workload is ~1.04x, not the ~8x assumed in
EigenMiniSat#88 / ouroboros#86. AOT's correctness and self-hosting value is
untouched by this — only the throughput argument.
-
Every consumer repo pays this, on every assignment, forever. This is the
largest single measured lever in the ecosystem.
-
It likely compounds with size. Entropy walk cost scales with list length,
so larger instances pay more per assignment. This may be a substantial part
of the per-conflict cost growth seen on the 5x5 lane (0.083 → 0.165
s/conflict), which was provisionally attributed to "bigger DB = more
propagation work". Untested — profiling 4x5 would settle it.
Direction (not prescriptive — this is the observer owner's call)
The obvious shape is to stop paying for bindings nothing interrogates.
compiler.c already computes an interrogated name set
(name_set_has(&c->interrogated, name)), which suggests a compile-time gate is
tractable. It cannot be a naive static elision: bare predicates read the
last-observed binding (the last-alias semantics), and report can reach
bindings that were never syntactically interrogated, so the gate has to be
whitelist-shaped (observe only what is provably reachable by an interrogation)
rather than blacklist-shaped.
A cheaper interim option worth measuring separately: make entropy lazy —
store the value and compute entropy only when a slot is actually read. That
preserves semantics exactly and moves the cost from every-assignment to
every-query.
Caveats
- One workload, one size (4x4). It is the real workload, but the 8.50x is
specific to a consumer that uses no observer features at all — that is the
best case, and programs that genuinely use observers will gain less.
- 8.50x is a ceiling, not an achievable win. A correct gate captures some
fraction of it.
- Baseline is n=1 (299.95 s) against n=3 for the probe arm; at 8.5x separation
the distributions do not come close to overlapping, but a shipped fix needs
the full n=5 treatment on both arms.
Reproduce
# EigenMiniSat @ 6754b29, EigenScript @ v0.39.0
perf record -F 199 -o perf44.data -- \
../EigenScript/src/eigenscript benchmarks/tseitin_ladder.eigs 4 4 999
perf report -i perf44.data --no-children --stdio --percent-limit 0.4
# external reference
eigenscript benchmarks/dump_tseitin_cnf.eigs 4 4 > t44.cnf # added this session
minisat t44.cnf /dev/null
Summary
Every assignment in every EigenScript program computes the entropy of the
assigned value, whether or not anything ever observes that binding. On a real
consumer workload that uses zero observer features, this is 88% of
runtime — a measured 8.50x ceiling.
src/compiler.c:1804emits the observe opcode unconditionally:vm.c:4630(OBSERVE_ASSIGN_LOCAL) then callsobserver_slot_update(
eigenscript.c:547) →compute_entropy(eigenscript.c:339), which for alist walks every element via
ent_child(eigenscript.c:331), each doing alog2. Assigning a list of length n costs O(n)log2calls.In a CDCL solver, watch-list assignment is the hottest loop in the program.
Measurement
Workload: EigenMiniSat Tseitin torus 4x4-odd, current CDCL defaults, v0.39.0,
devbox (Celeron N3350). Real workload, not a proxy. The solver contains no
observe/report/predicate usage anywhere (grepcount: 0 files).perf record -F 199, self time:ent_child__log2_sse2(libm)entropy_of_numcompute_entropy_implvm_run_ex(interpreter dispatch)gc_collect_impljit_helper_index_get_int_malloc+malloc_consolidateCeiling probe
Semantics-breaking, timing-only: early-return from
observer_slot_updateandobserver_slot_update_numbehind an env var, so one byte-identical binaryserves both arms. (Patch was reverted; tree is clean.)
Counters byte-identical across both arms (
conflicts=9986 resolutions=33873),confirming the probe changed only observer bookkeeping.
External reference
Native MiniSat 2.2.1 on the byte-identical CNF, same box: 0.25 s (and it
uses 84,150 conflicts to our 9,986 — our search is better, our throughput is
not). Gating the observer moves us from ~1,200x native to ~141x.
Why this matters beyond one consumer
The JIT is a net 3.0% LOSS on this workload (299.95 s on / 290.83 s off,
counters identical). Now explained: it cannot win, because only 4.21% of time
is dispatch. It pays compile cost against a 4% slice.
scanned=62 compiled=56, so it is compiling fine — there is just nothing there to get.This is a flag for the AOT performance case. AOT is positioned as the
strategic native-performance path, but it eliminates interpreter dispatch,
and dispatch is 4.21% here. Unless AOT also elides observer updates, its
ceiling on this workload is ~1.04x, not the ~8x assumed in
EigenMiniSat#88 / ouroboros#86. AOT's correctness and self-hosting value is
untouched by this — only the throughput argument.
Every consumer repo pays this, on every assignment, forever. This is the
largest single measured lever in the ecosystem.
It likely compounds with size. Entropy walk cost scales with list length,
so larger instances pay more per assignment. This may be a substantial part
of the per-conflict cost growth seen on the 5x5 lane (0.083 → 0.165
s/conflict), which was provisionally attributed to "bigger DB = more
propagation work". Untested — profiling 4x5 would settle it.
Direction (not prescriptive — this is the observer owner's call)
The obvious shape is to stop paying for bindings nothing interrogates.
compiler.calready computes aninterrogatedname set(
name_set_has(&c->interrogated, name)), which suggests a compile-time gate istractable. It cannot be a naive static elision: bare predicates read the
last-observed binding (the
last-alias semantics), andreportcan reachbindings that were never syntactically interrogated, so the gate has to be
whitelist-shaped (observe only what is provably reachable by an interrogation)
rather than blacklist-shaped.
A cheaper interim option worth measuring separately: make entropy lazy —
store the value and compute entropy only when a slot is actually read. That
preserves semantics exactly and moves the cost from every-assignment to
every-query.
Caveats
specific to a consumer that uses no observer features at all — that is the
best case, and programs that genuinely use observers will gain less.
fraction of it.
the distributions do not come close to overlapping, but a shipped fix needs
the full n=5 treatment on both arms.
Reproduce