status: decode wavefront reserved-compute path measured owner: emel last updated: 2026-06-26
Use co_sm first at the graph execution boundary.
- Infrastructure surface:
src/emel/sm.hpp - First inference actor:
src/emel/graph/processor/sm.hpp - First current consumer:
src/emel/graph/sm.hppthroughaction::request_execute - Experimental payoff path: standalone
src/emel/text/generator/decode_wavefront/**using graph-owned reserved compute
This is not a plan to make one SML dispatch faster. The useful speedup path is to make graph execution schedulable at a bounded phase boundary so a later decode wavefront can keep compatible sequences ready for the same kernel route and weight stream. Single-request latency must remain neutral before the wavefront path is allowed to ship.
Review update: production text/generator decode remains on direct graph dispatch. The current
decode wavefront actor is a standalone component and benchmark target until a maintained
multi-lane integration proves a benefit without regressing batch-1 latency.
async is not deferred by definition. An async/coroutine dispatch is compatible with the EMEL
RTC actor model when completion is driven and observed before the enclosing top-level dispatch
returns. The forbidden case is hidden work escaping the RTC boundary: an incomplete task,
continuation, scheduler work item, callback handle, mailbox entry, or post-for-later queue.
stateforward::smlis a namespace alias to the underlying SML implementation in the configured Stateforward dependency, so EMEL code must usestateforward::sml::utility::co_smrather than namingboost::smldirectly.src/emel/sm.hppnow includesstateforward/sml/utility/co_sm.hpp, exposesemel::co_sm,emel::bool_task, scheduler policy aliases, and a fixed no-heap coroutine allocator.emel::co_smdefaults toemel::policy::coroutine_scheduler<emel::policy::inline_scheduler>andemel::policy::coroutine_allocator<emel::policy::fixed_coroutine_allocator<>>.src/emel/graph/processor/sm.hppnow inherits fromemel::co_sm<model, action::context, inline_co_policy>and keeps its publicprocess_event(const event::execute &)wrapper synchronous by drivingprocess_event_async(...).result()to completion before returning.src/emel/graph/actions.hppdispatches graph execution throughctx.processor_actor.process_event_async(request).result()within the same graph RTC chain.src/emel/graph/sm.hppnow exposesevent::compute_reserved, an internal reserved-compute path that reuses the graph reservation output and enters processor execution without running the graph assembler path again.src/emel/text/generator/decode_wavefront/sm.hppuses bounded static-schedulerco_smand dispatches compatible lanes through graph-ownedcompute_reservedevents.src/emel/text/generator/actions.hppno longer routes single-lane production decode through the wavefront actor; it dispatches the already-selected graph compute request directly.tests/sm/sm_policy_tests.cppcovers the wrapper surface: default inline scheduler, sync dispatch, inlineprocess_event_async(...).result(), error normalization, context injection, scheduler access, and fixed allocator exhaustion.docs/rules/sml.rules.mdandAGENTS.mdnow explicitly allow RTC async/coroutine dispatch while forbidding hidden deferred work.docs/third_party/sml.mdnow documents the inlineemel::co_smdefault.
- No
sml::process_queue,sml::defer_queue, user mailbox, background worker, or hidden post-for-later mechanism. - No dynamic allocation during dispatch. Coroutine frames must use fixed storage or reject the dispatch; no heap fallback is acceptable in hot paths.
- No runtime behavior selection in actions, detail helpers, coroutine bodies, or awaitables. Runtime decisions stay in guards and transition rows.
- No coroutine use inside kernels, logits scans, sampler loops, tokenizer inner loops, renderer loops, tensor-element loops, packing, quant/dequant, or matmul/attention numeric loops.
- No public API exposure of coroutine tasks or scheduler internals.
- No performance claim from
co_smadoption alone. Claims require benchmark evidence. - Snapshot updates require explicit user consent.
Evidence:
src/emel/sm.hppexposes the EMEL wrapper around Stateforward's utilityco_sm.emel::policy::fixed_coroutine_allocatorreturnsnullptron pool exhaustion instead of falling back to heap allocation.- Existing
emel::smusers are untouched. emel::co_smmirrors the existing contextless/contextful wrapper shape and normalizeserror_outresults likeemel::sm.process_event_asyncin the EMEL wrapper observes completion before returning and always returns a normalized immediateemel::bool_task; incomplete scheduler work is not allowed to escape the RTC boundary.
Validation already run:
cmake --build build/zig --target emel_tests_bin -j2
ctest --test-dir build/zig -R '^emel_tests_sm$' --output-on-failureResult: passed.
Evidence:
src/emel/graph/processor/sm.hppusesemel::co_smwith an inline scheduler.- The public graph processor
event::executeentrypoint still creates the sameevent::execute_ctxandevent::execute_step, drives the inline async base dispatch to completion, and returns only after the RTC chain finishes. src/emel/graph/actions.hppuses the processor async execute wrapper and observes completion before the graph compute action returns.- No transition rows were moved into coroutine bodies.
Validation already run:
ctest --test-dir build/zig -R '^emel_tests_kernel_and_graph$' --output-on-failureResult: passed.
Current status: focused unit tests and the corrected decode-wavefront benchmark pass; the repo quality gate does not yet pass for this expanded wavefront change set. The strict LFM2 generation comparison now correctly fails on x86_64 because the runtime uses the shared Q4 fallback instead of an optimized Q4 kernel.
A current changed-file scoped gate was run with
EMEL_QUALITY_GATES_BENCH_SUITE=decode_wavefront and the temporary clang-format shim in
/tmp/emel-clang-format-venv/bin. It selected the decode wavefront benchmark runner, passed the
legacy SML scan, Zig build, dependency manifest freshness checks, parity skip, and fuzz skip, then
failed on the snapshot blockers below.
Gate blockers:
bench_snapshot: failed because the new focused benchmark rowsdecode_wavefront/batch1,decode_wavefront/batch4, anddecode_wavefront/batch8have no approved baseline insnapshots/bench/benchmarks_compare.txt.lint_snapshot: failed because new source/test files undersrc/emel/text/generator/decode_wavefront/**andtests/text/generator/decode_wavefront/lifecycle_tests.cppare not yet listed insnapshots/lint/clang_format.txt.- Snapshot updates require explicit user approval, so
snapshots/bench/benchmarks_compare.txt,snapshots/lint/clang_format.txt, andsnapshots/quality_gates/timing.txtwere not updated.
Code-owned review fixes completed after the first failed gate:
tools/bench/bench_runner.cppnow treats x86_64 and AArch64 as optimized flash hosts, matching existing generator tests and runtime counters.- LFM2 generation quantized evidence validation again requires optimized Q4 and optimized Q6 evidence on all hosts, with no shared fallback and no unrelated Q2/Q3/Q8 routes.
src/emel/kernel/x86_64/**now reports shared Q4 fallback dispatches, so a missing optimized Q4 path is visible in generation evidence instead of looking like no Q4 path ran.
Validation for those fixes:
EMEL_BENCH_ITERS=100 \
EMEL_BENCH_RUNS=3 \
EMEL_BENCH_WARMUP_ITERS=10 \
EMEL_BENCH_WARMUP_RUNS=1 \
scripts/bench.sh --compare --suite=generationResult: failed as expected on this x86_64 host because optimized Q4 is still missing. The run
reported optimized_q4_dispatch_calls=0, shared_q4_dispatch_calls=2378,
optimized_q6_dispatch_calls=291, and shared_q6_dispatch_calls=0.
Current decode wavefront benchmark evidence after review fixes:
decode_wavefront/batch1 emel.cpp 417.890 ns/op, reserved-scalar-baseline 340.800 ns/op, ratio=1.226x
decode_wavefront/batch4 emel.cpp 1533.400 ns/op, reserved-scalar-baseline 1355.200 ns/op, ratio=1.131x
decode_wavefront/batch8 emel.cpp 2930.290 ns/op, reserved-scalar-baseline 2713.990 ns/op, ratio=1.080x
Interpretation: the previous apparent speedup was from comparing reserved compute against full graph assemble+compute. Against direct per-lane reserved compute, the current wavefront component is slower in this fixture and is not ready for production generator integration.
src/emel/text/generator/decode_wavefront/actions.hpp
src/emel/text/generator/decode_wavefront/context.hpp
src/emel/text/generator/decode_wavefront/errors.hpp
src/emel/text/generator/decode_wavefront/events.hpp
src/emel/text/generator/decode_wavefront/guards.hpp
src/emel/text/generator/decode_wavefront/sm.hpp
tests/text/generator/decode_wavefront/lifecycle_tests.cpp
Gate command:
PATH="/tmp/emel-clang-format-venv/bin:$PATH" \
EMEL_QUALITY_GATES_CHANGED_FILES="AGENTS.md \
docs/rules/sml.rules.md \
docs/third_party/sml.md \
src/emel/sm.hpp \
src/emel/graph/actions.hpp \
src/emel/graph/context.hpp \
src/emel/graph/events.hpp \
src/emel/graph/guards.hpp \
src/emel/graph/processor/sm.hpp \
src/emel/graph/sm.hpp \
src/emel/text/generator/actions.hpp \
src/emel/text/generator/context.hpp \
src/emel/text/generator/decode_wavefront/actions.hpp \
src/emel/text/generator/decode_wavefront/context.hpp \
src/emel/text/generator/decode_wavefront/errors.hpp \
src/emel/text/generator/decode_wavefront/events.hpp \
src/emel/text/generator/decode_wavefront/guards.hpp \
src/emel/text/generator/decode_wavefront/sm.hpp \
tests/sm/sm_policy_tests.cpp \
tests/graph/graph_tests.cpp \
tests/graph/processor/processor_action_branch_tests.cpp \
tests/text/generator/decode_wavefront/lifecycle_tests.cpp \
tools/bench/CMakeLists.txt \
tools/bench/bench_cases.hpp \
tools/bench/bench_dependency_manifest.cpp \
tools/bench/bench_disabled_cases.cpp \
tools/bench/bench_runner.cpp \
tools/bench/bench_runner_registry.cpp \
tools/bench/dependency_manifest.txt \
tools/bench/graph/processor_bench.cpp \
tools/bench/text/generator/decode_wavefront_bench.cpp \
coroutine-plan.md" \
EMEL_QUALITY_GATES_BENCH_SUITE=decode_wavefront \
scripts/quality_gates.shResult: failed only for the snapshot-baseline blockers above.
Goal: prove the no-op co_sm graph processor is neutral before adding any async call surface or
decode wavefront behavior.
Evidence:
tools/bench/graph/processor_bench.cppadds a focusedgraph_processorsuite.- The EMEL lane uses the current
emel::graph::processor::sminlineco_smwrapper. - The reference lane uses a benchmark-local
emel::sm<processor::model, action::context>wrapper over the same transition table. - Cases cover invalid request rejection, reused-buffer success, allocation-required success, lifecycle gate/publish/release, and done/error callback publication.
tools/bench/CMakeLists.txt,tools/bench/bench_cases.hpp,tools/bench/bench_disabled_cases.cpp, andtools/bench/bench_runner_registry.cppregister the suite without requiring llama.cpp.tools/bench/bench_runner.cppprints graph-processor compare rows asreference-baselineinstead ofllama.cpp.
Validation run:
EMEL_BENCH_ITERS=200000 \
EMEL_BENCH_RUNS=9 \
EMEL_BENCH_WARMUP_ITERS=10000 \
EMEL_BENCH_WARMUP_RUNS=1 \
scripts/bench.sh --compare --suite=graph_processorResult:
| case | inline co_sm |
emel::sm baseline |
ratio |
|---|---|---|---|
graph/processor_alloc |
350.723 ns/op | 357.034 ns/op | 0.982x |
graph/processor_invalid |
24.942 ns/op | 24.958 ns/op | 0.999x |
graph/processor_reused |
305.952 ns/op | 315.271 ns/op | 0.970x |
Interpretation:
- The no-op graph processor
co_smconversion passes the neutrality checkpoint. - The benchmark has low-single-digit run-to-run movement, including earlier samples where successful dispatch was slightly slower and later samples where it was slightly faster.
- Treat the result as neutral infrastructure, not an inference speedup claim.
- Do not expand this no-op conversion into generator/decode paths as a performance feature.
- Use the benchmark as the regression guard for future coroutine candidates.
Generation comparison still required before any inference-throughput claim:
EMEL_BENCH_ITERS=1 \
EMEL_BENCH_RUNS=1 \
EMEL_BENCH_WARMUP_ITERS=0 \
EMEL_BENCH_WARMUP_RUNS=0 \
EMEL_GENERATION_WORKLOAD_ID=qwen3_single_user_hello_max_tokens_1_v1 \
scripts/bench.sh --compare --suite=generationGoal: make graph execution callable through process_event_async without changing generator or
graph RTC semantics.
Status: completed and measured. This is not an inference speedup by itself.
Evidence:
processor::sm::process_event(const event::execute &)delegates toprocess_event_async(ev).result().processor::sm::process_event_async(const event::execute &)preserves the dispatch-localexecute_ctx/execute_stephandoff and returns an immediateemel::bool_task.graph::action::request_executecallsctx.processor_actor.process_event_async(request).result().graph_processor_process_event_async_execute_completes_in_rtcproves the public async execute wrapper publishes callbacks and output before.result()is observed.- The existing
graph_machine_compute_lifecycle_dispatch_is_alloc_freetest now covers graph compute through the async processor execution path.
Acceptance:
graph::smandtext::generator::smremain synchronous RTC actors.- No generated token can observe partially completed graph work.
- No hidden scheduler work survives return from the top-level dispatch.
- Allocator counters show zero graph compute hot-path heap allocation.
Validation run:
cmake --build build/zig --target emel_tests_bin -j2
ctest --test-dir build/zig -R '^emel_tests_sm$' --output-on-failure
ctest --test-dir build/zig -R '^emel_tests_kernel_and_graph$' --output-on-failure
EMEL_BENCH_ITERS=200000 \
EMEL_BENCH_RUNS=9 \
EMEL_BENCH_WARMUP_ITERS=10000 \
EMEL_BENCH_WARMUP_RUNS=1 \
scripts/bench.sh --compare --suite=graph_processorResult from the latest repeated sample:
| case | async co_sm graph processor |
emel::sm baseline |
ratio |
|---|---|---|---|
graph/processor_alloc |
349.161 ns/op | 344.381 ns/op | 1.014x |
graph/processor_invalid |
32.150 ns/op | 26.940 ns/op | 1.193x |
graph/processor_reused |
316.821 ns/op | 310.523 ns/op | 1.020x |
Interpretation:
- The coroutine-driven graph processor is implemented and remains RTC.
- Successful dispatch has a low-single-digit overhead against the direct
emel::smbaseline. - Invalid rejection is materially slower because the async wrapper overhead dominates a tiny failure path.
- This phase proves semantics and establishes a benchmark guard; it does not speed inference.
Goal: turn co_sm into an inference speedup by batching compatible decode work across sequences.
Status: completed for the bounded graph-compute wavefront target.
Evidence:
src/emel/text/generator/decode_wavefront/**defines a bounded generator-owned wavefront actor with explicit lane stages for up to 8 lanes.- Guards require compatible model identity, backend identity, kernel kind, attention mode, kernel route, output contract, dtype/layout contract, quantized contract, step size, and token count.
- The wavefront path does not share mutable lane context. Each lane carries its own graph actor, graph compute request, compatibility key, and acceptance flag.
- The graph wrapper owns the optimization boundary through
event::compute_reserved; wavefront does not reach into graph actions or assembler internals. - Reserved compute requires a successful graph reservation first, seeds the compute output from the reservation, and skips normal graph assemble hints.
process_event_asyncon the wavefront observes the bounded static-scheduler dispatch before returning and exposes a normalized immediateemel::bool_task, preserving RTC semantics.- No background worker, mailbox,
defer_queue,process_queue, or hidden post-return work was introduced.
Acceptance:
- Deterministic dispatch and first-lane failure behavior are covered by decode wavefront tests.
- Scheduler depth is bounded by
fifo_scheduler<16u, 64u>and lane count is bounded byevent::k_max_lanes == 8. - Batch-1/4/8 performance is not accepted for production integration yet. The corrected reserved-scalar baseline shows current wavefront overhead rather than a grouping win.
Validation run:
cmake --build build/zig --target emel_tests_bin -j2
./build/zig/emel_tests_bin --no-breaks --test-case="*graph*"
./build/zig/emel_tests_bin --no-breaks --test-case="*x86_64*q4*"
./build/zig/emel_tests_bin --no-breaks --test-case="co_sm*"
./build/zig/emel_tests_bin --no-breaks --test-case="decode wavefront*"
ctest --test-dir build/zig -R '^emel_tests_kernel_and_graph$' --output-on-failure
ctest --test-dir build/zig -R '^emel_tests_generator_and_runtime$' --output-on-failure
ctest --test-dir build/zig -R '^emel_tests_sm$' --output-on-failure
EMEL_BENCH_ITERS=2000 \
EMEL_BENCH_RUNS=5 \
EMEL_BENCH_WARMUP_ITERS=100 \
EMEL_BENCH_WARMUP_RUNS=1 \
scripts/bench.sh --suite=decode_wavefront --compareResult:
| case | wavefront path | reserved-scalar baseline | ratio |
|---|---|---|---|
decode_wavefront/batch1 |
417.890 ns/op | 340.800 ns/op | 1.226x |
decode_wavefront/batch4 |
1533.400 ns/op | 1355.200 ns/op | 1.131x |
decode_wavefront/batch8 |
2930.290 ns/op | 2713.990 ns/op | 1.080x |
Interpretation:
- The first useful
co_smtarget remains the graph execution boundary, but the current decode-wavefront component is not a production win. - The earlier speedup claim was an apples-to-oranges comparison against full graph compute. The corrected benchmark measures wavefront orchestration against direct reserved graph dispatch.
- The current implementation still drains inside the top-level RTC call; that is intentional for actor correctness. Future external completion backends must suspend only at explicit phase boundaries without changing public graph/generator semantics.
Goal: get co_sm + thread_pool_scheduler to beat the single-sm baseline and
llama.cpp on a realistic parallel decode workload (independent per-lane GEMVs).
Status: achieved at small per-lane dims (the inter-op-parallelism regime), with a prerequisite scheduler deadlock fixed along the way.
emel::policy::thread_pool_scheduler deadlocked under rapid repeated fork/join,
most readily when lane count == worker count (the decode wavefront's 8-lane,
8-worker config). A single dispatch rarely hit it, so existing tests passed while
the bug was latent. Two independent races in the join latch:
join_group's close/complete handshake was a Dekker pattern with release/acquire ordering. StoreLoad reordering letwait()miss the final completion while the last completer missed the close, stranding the wakeup.- The per-group
std::binary_semaphorewas destroy-during-release: groups are stack-reused each round, so the waiter could return and destroy the group while the last completer was still inside libc++release()/notify (UAF).run_or_schedule_and_wait's localdonesemaphore had the same flaw.
Fix: replaced the semaphore/closed handshake with a lifetime-safe spin-join on
pending_ (a completer's last touch is its decrement, so wait() returns only
after every completer is done); run_or_schedule_and_wait spins on a local
atomic<bool> the worker sets last. Added emel::policy::cpu_relax(). New
regression test thread_pool_scheduler_ref_fork_join_survives_rapid_repeated_rounds
(20000 rounds x 8 lanes). Validated: 80M+ batch-8 fork/joins with 0 stalls.
Workers now spin-claim a wake permit (bounded k_idle_spin_budget) before
falling back to a blocking acquire, keeping the pool warm across a burst of
fork/joins and removing ~3-4us of resleep/wakeup latency per round (the same
warm-polling strategy ggml's threadpool uses). The permit-per-task invariant is
preserved, so there is no drift or lost-wakeup regression.
In-repo, via the actual production decode wavefront actor (thread-pool co_sm)
through scripts/bench.sh --compare --suite=decode_wavefront. New realistic
cases run a decode-representative f32 GEMV (y = W@x, dim x dim, independent
weights per lane); the gemv_* cases compare against the single graph sm
(reserved scalar), the ggml_* cases against a ggml reference (independent
mul_mat lanes, warm threadpool, same core budget). Default dim=256:
| case (batch 8) | emel.cpp | baseline | result |
|---|---|---|---|
gemv_batch8 (vs single-sm) |
~8.4-10.5 us | ~33 us | 3.1-3.9x faster |
ggml_batch8 (vs llama.cpp) |
~9.1-9.3 us | ~12.7 us | 1.37-1.41x faster (3 runs) |
The single-sm win holds at every dim (3-6x). The llama.cpp win holds at small dims (dim<=256, ~1.4x) where inter-op parallelism dominates ggml's intra-op threading; the crossover is ~dim 384-512, where ggml's hand-optimized blocked kernel catches up. That crossover is a kernel-quality matter, not a scheduling one: the scheduler delivers near-linear parallelism (up to 7.85x on 8 lanes for bare GEMV lanes), and its overhead floor is ~5-10us (idle-pool worker wakeup), dwarfed by real decode work.
Contrast: the original trivial-work cases (batch8, kernel = *calls += 1)
still show the wavefront 2-4x slower, confirming the earlier "wavefront is
slower" result was a fixture artifact, not a scheduler limitation.
Caveat: EMEL's inter-op parallelism beats ggml only for independent-weight lanes (concurrent requests / multi-model / MoE). For shared-weight batched decode, ggml batches sequences into one GEMM and reuses the weight stream (bandwidth-optimal); the comparison here is framed as independent-weight concurrent lanes accordingly.
Goal: turn the proven thread-pool fork/join into a maintained single-request hot-path win for
prefill GEMM and per-token decode GEMV, and remove ggml's ith/nth thread-partition fields so
view slicing is the only parallelism model in the kernel event contract.
Decision record:
ith/nthwere declared on every kernel op event but never used for partitioning; the only read site was a validator that rejected anything butith==0 && nth==1. The architecture-native slice descriptor is thetensor_viewitself: a row slice is a smaller view, the kernel computes whatever its views describe, and partition policy lives only at the orchestration fork site. The fields and their validator clause are deleted; events remain complete work descriptions with no thread identity.
Implementation:
src/emel/text/generator/detail.hppowns the lanes:k_matmul_lanes == 8per-laneemel::kernel::smactors plus anstd::optionalthread_pool_scheduler<8, 16, 128>engaged once inprepare(). A parallel dispatch forks one logical mul_mat into pack-group-aligned contiguous row-slice events (compute_matmul_row_slices, groups of 8 for*_x8, 4 for*_x4, 1 otherwise), the caller computes slice 0 while pool workers compute the rest, and the join completes before the action returns. Rejected submits run the same slice inline, so a pool-worker caller (wavefront lane) degrades to serial automatically — lanes-first composition.- Lane mode is a compile-time template parameter threaded through the matmul helpers and
runners; route guards choose it: prefill parallel rows require the pool engaged and
prompt_token_count >= k_parallel_min_prefill_tokens(8), decode parallel rows requiren_embd >= k_parallel_min_gemv_dim(1024) so tiny models keep the scalar route. Parallel contract rows sit above their serial siblings for flash materialized + preselected prefill chunk routes and flash materialized + preselected decode scalar routes. - Evidence counters live per kernel actor, so audit reads sum the primary kernel and every lane
(
compute_kernel_counter_total);kernel_dispatch_callsand route counters stay once per logical matmul at the fork site.
Evidence (Ryzen 9 5950X, zig c++ -O3 -mavx2 -mfma):
parallel_matmul/gemv_f32 emel.cpp 285062 ns/op, reference-baseline 2419432 ns/op, ratio=0.118x
parallel_matmul/gemv_q8_0 emel.cpp 54841 ns/op, reference-baseline 371154 ns/op, ratio=0.148x
parallel_matmul/gemv_q4_k emel.cpp 68648 ns/op, reference-baseline 470182 ns/op, ratio=0.146x
parallel_matmul/gemv_q6_k emel.cpp 43786 ns/op, reference-baseline 282983 ns/op, ratio=0.155x
parallel_matmul/gemm8_f32 emel.cpp 156122 ns/op, reference-baseline 1265074 ns/op, ratio=0.123x
Interpretation: 6.5x-8.5x over serial single-kernel dispatch at dim 2048 across dtypes — the inter-op fork/join regime delivering near-linear scaling on 8 lanes, now on the maintained matmul dispatch path rather than a standalone component.
Correctness:
- Row slices write disjoint dst rows and reorder no reductions, so parallel output is
bit-identical to serial;
tests/text/generator/parallel_matmul_tests.cppproves slice arithmetic, group alignment, and f32/q8_0 serial-vs-parallel byte equality, and the full generator fixture suites pass with prompts >= 8 tokens taking the parallel prefill route. - The strict LFM2 x86_64 generation evidence failure is unchanged and pre-existing
(
optimized_q4_dispatch_calls=0 shared_q4_dispatch_calls=2378 optimized_q6_dispatch_calls=291— identical counts to the pre-cutover run, which also proves lane-counter aggregation preserves evidence exactly).
Goal: measure the view-sliced parallel matmul against llama.cpp/ggml at the same core budget instead of only against EMEL's own serial dispatch, per the reference-comparison rules.
Kernel-class lane (parallel_matmul/ggml_* cases): the EMEL side runs the identical 8-lane
fork/join as the plain-named cases; the reference side runs the same logical matmul as one ggml
mul_mat node on a warm 8-thread ggml threadpool (the decode_wavefront reference pattern).
Operand class is plain GGUF-native blocks on BOTH sides — this exercises EMEL's shared
(non-repacked) kernels, not the repacked x4/x8 kernels the production decode routes dispatch
after prepare().
Evidence (Ryzen 9 5950X, dim 2048, 8 threads both sides, iter=2000 runs=5):
parallel_matmul/ggml_gemm8_f32 emel.cpp 170008 ns/op, llama.cpp 201689 ns/op, ratio=0.843x
parallel_matmul/ggml_gemv_f32 emel.cpp 284070 ns/op, llama.cpp 29684 ns/op, ratio=9.570x
parallel_matmul/ggml_gemv_q8_0 emel.cpp 55579 ns/op, llama.cpp 20757 ns/op, ratio=2.678x
parallel_matmul/ggml_gemv_q4_k emel.cpp 68174 ns/op, llama.cpp 16335 ns/op, ratio=4.173x
parallel_matmul/ggml_gemv_q6_k emel.cpp 44451 ns/op, llama.cpp 17805 ns/op, ratio=2.496x
Interpretation:
- Prefill-shape GEMM: EMEL's inter-actor row slicing beats ggml's intra-op chunking (0.843x).
- Decode-shape GEMV: the gap is per-kernel arithmetic on plain blocks, not orchestration — the EMEL parallel numbers match serial/8 scaling exactly, and the serial kernels trail ggml's vec_dot by the same factors (plain-q4_k ~4x, plain-q8_0 ~2.7x, f32 GEMV ~10x, consistent with the known missing optimized plain-Q4 kernel and a near-scalar f32 GEMV path).
- These rows are a kernel-class comparison and must not be quoted as production decode numbers: production decode dispatches the repacked x4/x8 kernels. The production-class cross-engine number is the end-to-end generation compare at matched reference threads (in progress; blocked for LFM2-architecture fixtures on x86 by the same strict Q4 evidence audit until either the optimized plain-Q4 kernel lands or the audit gains a quant-class-aware LFM2+Q8_0 branch).
Follow-up work surfaced by this comparison: optimized plain-Q4 GEMV kernel (already the known blocker), SIMD f32 GEMV, and a repacked-operand cross-engine lane once llama.cpp's x86 repack path is wired into the reference fixture.
End-to-end lane (LFM2.5-230M-Q8_0, prompt "hello", EMEL_BENCH_REFERENCE_THREADS=8 so the
reference runs the same 8-core budget as EMEL's lane pool; 1-thread reference rows kept for
context):
max_tokens_1 emel.cpp 443.0 ms/op, llama.cpp 27.1 ms/op (8t), ratio=16.337x [1t: 130.7 ms, 3.407x]
max_tokens_100 emel.cpp 1002.6 ms/op, llama.cpp 514.0 ms/op (8t), ratio=1.950x
Decomposition (t100 minus t1, over 99 decode tokens): EMEL ~5.65 ms/token vs llama.cpp ~4.92 ms/token — steady-state decode is within ~1.15x at matched threads, consistent with both sides being memory-bandwidth-bound on q8_0 GEMV at this model size. The end-to-end gap is concentrated in the first-token path: EMEL spends ~443 ms before the first sampled token where llama.cpp spends ~27 ms at 8 threads. The "hello" prompt is only a handful of tokens, so this is not GEMM volume; profiling the EMEL first-token/session path (prompt format, tokenize, prefill graph build/plan, first dispatch) is the top follow-up from this lane.
Goal: use coroutine suspension only where an actual external completion source exists.
Valid future boundaries:
- accelerator submission/completion
- OS-backed cold-load or staged-read completion
- platform DMA/NPU completion
Invalid boundaries:
- CPU AArch64 or x86_64 kernel inner loops
- software prefetch
- per-token sampler loops
- synthetic sleep/poll loops
Acceptance:
- Async backend routes are explicit guarded transitions.
- Synchronous CPU routes remain direct and are not slower.
- The EMEL lane remains EMEL-owned; no llama.cpp/ggml object is shared into runtime execution.
Status: completed for the OS-backed staged-read boundary (streaming weights
milestone, 2026-07-02). The external-completion scheduler policy landed
upstream-first (stateforward/sml.cpp PR #16: completion_source, park/drain
in co_sm, ascending commit sweep, dispatch-thread-only resumption; emel pin
follows the PR branch until merge). The first consumer is
emel::model::tensor::window — a tensor-owned streaming weight window whose
acquire dispatches suspend on in-flight slot loads fired by a 2-lane I/O pool
running io/staged_read copies from a whole-file mmap source, with willneed/
dontneed advise ahead of and behind the ring. The generator streams decode
through it behind one engagement guard (guard_decode_stream_window_ready)
with streamed rows above their resident/parallel siblings; streamed-vs-
resident token parity is proven through the public generator path, and the
opt-in weight_streaming bench compares emel window / emel mmap / llama.cpp
mmap lanes under an optional systemd-run memory cap
(scripts/bench.sh --memory-max=...). Follow-ons recorded in the milestone
memory: wavefront x streaming multiplexing, aarch64 repack-on-acquire,
session context caps for honest capped benches, MoE expert-granular
streaming, co_sm-throughout sweep.
Before claiming the graph processor coroutine phase is complete:
emel_tests_smpasses.emel_tests_kernel_and_graphpasses.- Changed-file scoped quality gate passes.
- A graph processor benchmark exists and compares
emel::smvs async inlineemel::co_sm. - No snapshots were updated without explicit approval.
- Code review confirms no hidden runtime behavior selection was moved into coroutine bodies, awaitables, actions, or detail helpers.
Before making any broader maintained-generation throughput claim:
- Generation benchmark evidence exists for single-token and multi-token maintained decode workloads, not only the focused decode wavefront microbenchmark.
- Snapshot/parity checks cover maintained generation fixtures.
- A wavefront or external-completion implementation exists at the maintained generation entrypoint, not only as a callable component.
co_smadds overhead but no speedup. Detection: graph processor benchmark. Response: keep graph processor onemel::smand useco_smonly where it preserves RTC or where a later completion is modeled as an explicit external event.- Scheduler becomes a hidden queue. Detection: SML rule review, callback-order tests, and escaping-task tests. Response: restrict inference paths to inline/immediate-drain scheduling until a bounded driver is explicitly approved.
- Coroutine frame allocates on hot path. Detection: allocator accounting and allocation instrumentation. Response: increase fixed pool or reject dispatch; never fall back to heap in hot paths.
- Awaitable chooses behavior. Detection: action/guard branch tests and code review. Response: move choice into guards and transition rows.
- Decode batching changes outputs. Detection: generation parity tests and compare summaries. Response: fix lane ordering and publication; do not accept drift as a performance tradeoff.
- Kernel route mismatch weakens parity claims. Detection: diagnostics and runtime contract counters. Response: benchmark only equivalent operand paths.
- After graph processor measurement: continue only if the no-op
co_smconversion is neutral. - After RTC
process_event_asyncwiring: continue only if RTC semantics and allocation guarantees are proven. - After decode wavefront implementation: focused correctness passed, corrected performance did not. Next checkpoint is either a real multi-lane scheduler integration that amortizes the overhead or a different coroutine boundary with external completion work.