Skip to content

Observer entropy is computed on EVERY assignment regardless of use: 88% of runtime / 8.50x ceiling on a consumer that uses no observer features #915

Description

@InauguralPhysicist

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

  1. 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.

  2. 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.

  3. Every consumer repo pays this, on every assignment, forever. This is the
    largest single measured lever in the ecosystem.

  4. 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

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions