Skip to content

refactor(sdk): split geniex-bench into per-concern modules - #1378

Merged
RemiliaForever (RemiliaForever) merged 7 commits into
mainfrom
refactor/split-bench-modules
Aug 28, 2026
Merged

refactor(sdk): split geniex-bench into per-concern modules#1378
RemiliaForever (RemiliaForever) merged 7 commits into
mainfrom
refactor/split-bench-modules

Conversation

@RemiliaForever

Copy link
Copy Markdown
Contributor

Summary

  • Split sdk/benchmark/benchmark.c (2174 lines, mixing argv parsing, model-manager resolution, run loops, aggregation and report writing) into per-concern modules — options.c / resolve.c / run.c / stats.c / report.c / util.c — with bench.h carrying the shared types and a map of which module owns what.
  • Collapse the copy-paste the split exposed: the --- prompt splitter (run_llm + run_vlm each carried a copy), the random-ids prefill block (run_llm + run_logits), the profile_datarun_result_t copy (both loops), the model-manager pull/get_paths dance (model + draft model), and three near-identical agg JSON writers. Dead json_field_dbl() and the (void) cast that silenced its unused-function warning go away with them.
  • Fix a model-manager path leak: run_one_cell's two early returns (--logits against a VLM, and the calloc failure) skipped the mm_* teardown that the four other exits performed. valgrind reports 113 bytes definitely lost before, 0 after.
  • Fix resolve_draft_via_mm logging o->draft_model after overwriting it, so the line read resolved draft <path> -> <path> instead of naming the id it resolved.
  • Drop three write-only run_result_t fields: is_warmup was never assigned, err[256] never touched, status only ever set to 0 and never read.
  • Reject flag values that produced plausible-looking reports from runs that never happened. --warmup below 0 makes warmup + repeat shorter than repeat, so the tail of the results array stayed calloc-zeroed and was reported as real 0 tok/s runs (--warmup -3 -r 5 gave three zero rows and a 0.0 median); --n-gen below 1 was silently replaced by the plugin's own 128-token default, so the report claimed an n_gen the run never used. Separately, a matrix line over 2047 bytes was split by fgets into a cell that ran with a truncated path plus a leftover tail reported as a bogus extra line under the wrong number.

Reviewing

Commit 1 is pure movement and can be skimmed: 42 of 45 function bodies are byte-identical to the original. The three exceptions are exactly what the split forces — the two globals that now cross a module boundary (g_token_callback_delay_us gains a setter, main's g_mm_inited teardown becomes mm_shutdown()), and one signature re-wrapped by clang-format after losing static (zero body change). The substance is in commits 2–5, which are 6–170 lines each.

Test plan

Every comparison below is against a build of the pre-split benchmark.c from main, run side by side:

  • --help: 120 lines byte-identical
  • per-cell JSON report: identical structure, and identical text skeleton (indentation and column alignment included) once the measured numbers are normalised
  • Markdown row: identical format
  • --logits JSON report: byte-identical (deterministic under a fixed seed)
  • --prompt-file segmentation: identical [sep ] / [gen ] output and exit codes across 4 cases — normal, leading + back-to-back + whitespace-padded ---, no separator at all, all-blank file
  • matrix mode (2 cells including one VLM, plus comment / blank / malformed lines): identical stdout and per-cell JSON
  • model-manager id and draft-model id resolution: identical (except the corrected draft log line)
  • 7 error paths (--logits with VLM, --logits with --prompt-file, --logits-top-n 0, missing --plugin, unknown arg, …): identical messages and exit codes
  • valgrind on the --logits + VLM early return: 113 bytes definitely lost on main, 0 after
  • the 4 new validations fire as intended, and the fgets length check does not false-positive on either edge case (final line without a trailing newline; line exactly 2047 bytes including the newline)
  • clang-format-18 clean; gcc -std=gnu11 -Wall -Wextra -Wshadow produces no warnings; local Linux CMake build green

Verified on Linux / CPU via the llama_cpp plugin only — no Snapdragon device to hand. The change is plugin-agnostic and touches no SDK headers, so there is no FFI surface to update.

Pure code movement, no behavior change: benchmark.c had grown to 2174
lines mixing argv parsing, model-manager resolution, run loops,
aggregation and report writing. Every function body moves verbatim —
bench.h carries the type definitions and a map of which module owns what.

The only edits to moved code are the two globals that now cross a module
boundary: g_token_callback_delay_us gets a set_token_callback_delay_us()
setter for parse_args to call, and main()'s g_mm_inited teardown becomes
mm_shutdown(). Signatures that lost `static` are re-wrapped by
clang-format.

Signed-off-by: RemiliaForever <remilia@koumakan.cc>
Behaviour-preserving cleanup of the modules split out in the previous
commit. The monolith had grown three near-identical copies of several
blocks; splitting it into files made them visible side by side.

Extracted:
  run.c      build_prompt_list()  — the "---" segment splitter, duplicated
                                    verbatim in run_llm and run_vlm (~45 lines
                                    each). run_vlm's copy had already lost the
                                    explanatory comments of the original.
             make_random_tokens() — vocab/BOS lookup + rand() fill, duplicated
                                    in run_llm and run_logits with two
                                    divergent error messages; the differing
                                    tail is now a `hint` argument.
             record_run()         — the 14-field ProfileData -> run_result_t
                                    copy, byte-identical in both run loops.
  resolve.c  mm_init_once()       — the lazy geniex_model_init guard, copied
                                    into both resolvers.
             mm_resolve()         — get_paths -> pull -> get_paths, ~37 lines
                                    duplicated between resolve_via_mm and
                                    resolve_draft_via_mm. `kind` supplies the
                                    "draft " prefix on the progress line.
             free_mm_paths()      — the four-pointer release block, inlined
                                    twice in run_one_cell.
  report.c   json_agg_stat()      — three fprintf calls differing only in the
                                    key. The key is padded to a fixed 15-column
                                    width, which reproduces the original
                                    hand-aligned output byte for byte.
  options.c  require_min()        — the >=1 flag check, written out six times.
  bench.h    cell_name()          — the `cell_id ? cell_id : "cell"` fallback,
                                    repeated at five call sites across two
                                    files. Small enough to stay inline.

Also drops report.c's json_field_dbl(), which had no callers and was kept
alive only by a `(void)json_field_dbl;` suppression.

Deliberately left alone: run_llm and run_vlm keep separate outer loops.
They differ in four real ways (create-input type, per-run chat templating,
the reset condition, the spec-decode log) and folding them would need a
callback struct that costs more than the ~15 lines it saves. The
per-call-site SDK error messages also stay as they are: each carries
distinct context (run index, operation name) that a shared helper would
flatten.

Verified byte-identical output against the pre-refactor binary on
Linux/CPU + llama_cpp across single-cell, --prompt-file with "---"
segments, --logits, and matrix mode (JSON, Markdown, stdout and stderr
all match once timings are normalised), plus all nine argv-validation
error paths.

Signed-off-by: RemiliaForever <remilia@koumakan.cc>
Interface-level cleanup of bench.h. Report structure (JSON schema,
Markdown row, stdout lines) and CLI flags are unchanged.

run_result_t dropped is_warmup / status / err[256]. After record_run()
was extracted, the last `r->status = 0` went with it and all three
fields had zero readers and zero writers. They were worse than dead
weight: the header advertised a per-run error protocol that does not
exist, so a reader would reasonably write `if (r->status != 0)` and it
could never fire. Every failure path exits the process; the struct now
says so.

agg_t's 18 flat doubles became three stat_t. The old names were also
inconsistent — ttft_ms_med carried a unit suffix, prefill_med and
decode_med did not. The struct existed three times over, so naming it
pays twice: json_agg_stat() drops from six parameters to three, and
stats.c loses summarize_full() entirely (summarize() now returns the
whole stat_t, and the median-only metrics just take .med).

--token-callback-delay-us moved into options_t. It was the only flag
that bypassed the struct, reaching run.c through a setter and a
file-static. That made it silently un-per-cell in matrix mode, where
`cell = *base` carries every other flag, and nothing said so. The
global existed only because an available channel was being wasted:
geniex.h has carried `void* user_data` next to `on_token` all along
(geniex.h:117, :543), and bench discarded it with `(void)user_data`.
The callback now reads the delay from the options it is handed.

write_json / write_md_row / write_logits_json return 0/1 instead of
calling exit(1) on a fopen failure. This one was a real defect, not a
style point: the resolvers and run_one_cell already return error codes
so that matrix mode can count a failed cell and continue, but an
unwritable report bypassed that and killed the process. Observed with
two cells and a bad --output-json-dir: the sweep loaded the model, ran
cell 1, printed `[ok  ] c1`, then died — cell 2 never ran, and stdout
still claimed success for cell 1. The sweep now runs both cells and
counts two failures. Single-cell mode still exits non-zero.

aggregate() takes int32_t rather than int, matching everything else.

Verified against the pre-change binary on Linux/CPU + llama_cpp across
single-cell, --prompt-file with "---" segments, --logits, --accuracy,
matrix mode, all argv-validation paths and --help: JSON, Markdown,
stdout and bench's own stderr lines are identical once timings are
normalised. The one stderr delta is the SDK's own TRACE dump of the
input struct, which now prints `user_data: 0x...` instead of
`user_data: nullptr` — that field being populated is the point.

Still deliberately unaddressed, both flagged for follow-up: options_t
mixes argv input, resolver-written derived state and output
destinations, which is why `const options_t*` means "this phase does
not mutate" rather than "this is input"; and (device_id, ngl) travels
as a pair through six of the exported functions while write_md_row
takes only one of them.

Signed-off-by: RemiliaForever <remilia@koumakan.cc>
Two groupings, both encoding a fact that was previously convention.

device_t holds the (device_id, ngl) pair. It was threaded separately
through six of the exported functions, and write_md_row took only ngl —
almost consistent, which reads worse than either extreme. The two values
are produced in exactly one place (geniex_resolve_device in run_one_cell,
after the --device-id / --n-gpu-layers overrides) and consumed together
everywhere else, so they are now one argument and write_md_row matches
the rest.

options_t's mm_* fields became a named `mm` sub-struct. The struct held
three different kinds of thing side by side: argv input, resolver-written
derived state, and output destinations. That is why `const options_t*`
only ever meant "this phase does not mutate" rather than "this is
input" — the reader could not tell which fields were still liable to
change. Nesting the resolver-written group puts the boundary in the type
instead of a comment, and options_t stays one flat copyable struct so
matrix mode's `cell = *base` is untouched.

Report structure and CLI flags unchanged; verified identical across
single-cell, --prompt-file segments, --logits, --accuracy, matrix mode,
the argv-validation paths and --help.

Signed-off-by: RemiliaForever <remilia@koumakan.cc>
Renames, each because the old name said the wrong thing:

  compute_model_size -> model_disk_bytes   "compute_" carries no
                                           information; the return is bytes
  resolve_via_mm     -> resolve_model_id   names what is resolved rather
  resolve_draft_via_mm -> resolve_draft_id than which subsystem does it
  write_json         -> write_cell_json    was asymmetric with its sibling
                                           write_logits_json
  aggregate          -> aggregate_runs     a bare verb is too generic for a
                                           shared header
  format_size        -> format_bytes       it formats a byte count

Comment trimming, keeping every load-bearing note (#1090, the QAIRT
prefill padding of #1194, the VLM reset rationale, the parser-free
metadata.json read, the geniex_model_paths_free allocator pairing, the
parent_path() anchor requirement) and dropping restatements of the
adjacent name. Two were not merely verbose but wrong or stale:

  - looks_like_path's comment claimed ids are rejected when the leading
    segment contains '.', which the function never implemented — it tests
    only the first character, so `a.b/c` resolves as an id. The comment
    now matches the code.
  - resolve.c still referred to mm_mmproj / mm_tokenizer, dead names since
    those fields moved into the `mm` sub-struct.

The #1090 shadowing rule and resolve_model_id's contract each existed in
two places after the module split; they now live once, on
options_t.model_path and on the bench.h declaration respectively.

Report structure and CLI flags unchanged, verified across the full
matrix of modes.

Signed-off-by: RemiliaForever <remilia@koumakan.cc>
run_one_cell had six return points and four copies of the cleanup block,
and four of the six never called free_mm_paths(): the resolve_draft_id
failure, the geniex_resolve_device failure, the "--logits is not
supported for VLM models" rejection, and the calloc failure. Each of
those can run after resolve_model_id() has already taken ownership of
the manager's heap paths in o->mm.

Restructured to a single exit through a `done` label, which both fixes
the leak and removes the duplication that caused it — one cleanup block
instead of four almost-identical ones.

valgrind on the --logits --vlm rejection path with a model-manager id:

  before   definitely lost: 113 bytes in 1 blocks
  after    definitely lost: 0 bytes in 0 blocks

Also in stats.c, aggregate_runs() now rejects n < 1 instead of letting
summarize() read values[0] and values[n-1] out of bounds. parse_args
validates --repetitions >= 1 so this was unreachable in practice, but it
was a cross-translation-unit invariant the compiler could not see and
the signature did not state — gcc had been warning about it since the
module split ('tmp' may be used uninitialized). The tree now builds
clean under -Wall -Wextra -Wshadow.

While there, summarize() sorts the caller's buffer in place rather than
mallocing and memcpying its own copy. The old comment justified the copy
with "so the caller's buffer is untouched", but its only caller refills
that scratch buffer completely before every call, so the copy bought
nothing and cost six malloc/memcpy/free round trips per cell.

README.md no longer calls this a "single-file C example" and points at
bench.h for the module map.

Report structure and CLI flags unchanged; verified identical across
single-cell, --prompt-file segments, --logits, --accuracy, matrix mode,
the argv-validation paths and --help, stderr included.

Signed-off-by: RemiliaForever <remilia@koumakan.cc>
…g the buffer

Three pre-existing defects, all reproducible on main. `--prompt-file`
batching was bolted onto machinery built for a single prompt: a results
array sized o->repeat, and a prompt_buf that options_t shares across
matrix cells and that the splitter rewrote in place.

  1. A batched file ran every segment but reported only the last. run_idx
     restarts at 0 per segment while runs[] holds o->repeat entries, so
     each segment overwrote the previous one's measurement. Three segments
     (3 / 19 / 6 prompt tokens measured individually) at -r 2 printed
     three [sep ] markers, ran six generations, and wrote two JSON
     entries, both carrying the last segment's numbers.

  2. In matrix mode every cell after the first ran only segment 1.
     build_prompt_list() tokenised prompt_buf in place and `cell = *base`
     shares that buffer, so cell 2 walked one that cell 1 had already cut
     with NULs: strchr() stopped at the first and the split yielded a
     single prompt. Nothing in the report showed it — two identically
     configured cells silently benchmarked different input.

  3. Any prompt file with trailing whitespace on a line was silently
     truncated there, batched or not. The separator test called
     rstrip(line), which writes '\0' in place, and only the line's own
     '\n' was restored afterwards; the prompt, read as a C string, ended
     at the first leftover NUL. This hit the plain timing path, not just
     batching: the same two-line file measured 15 prompt tokens without
     trailing spaces and 5 with them.

Splitting on `---` is now an --accuracy feature only. That is what it is
for — eyeballing several prompts' output in one invocation — and in a
timing run the segments have different lengths, so a median of
prefill_tps across them would mix populations. Every other mode feeds the
file verbatim as one prompt, `---` lines included, so no existing
invocation changes its exit status.

The fixes are simplifications rather than additions:

  - Resolving the prompt list is parse-time input processing, not
    something each run loop should redo, so it happens once in
    parse_args: `o->accuracy ? build_prompt_list(...) : one_prompt(...)`.
    A timing run never enters the splitter at all, and defect 2 becomes
    structurally impossible because no per-cell tokenisation is left to
    leave leftovers behind. It also makes `const options_t*` honest,
    since nothing mutates prompt_buf during a run.

  - The separator test is now a read-only is_separator(), which fixes
    defect 3 inside --accuracy and lets the loop drop the
    cut-then-restore of each line's newline. Only real separators are
    overwritten, so every segment keeps its text byte for byte.

  - run_one_cell sizes runs[] as repeat * prompt_count and passes that
    count to aggregate_runs() and write_cell_json(), instead of three
    places re-deriving it from o->repeat. The run loops track one output
    index across segments. That closes the implicit-array-length coupling
    noted when bench.h was reviewed.

Verified against the pre-change binary. Fixed: batched --accuracy now
reports prompt_tokens [3, 19, 6] matching the three segments measured
individually, both matrix cells report all three, a trailing-space file
measures 15 tokens instead of 5, and a batched file in a timing run
measures the whole 30-token text instead of the last 6-token segment.
Unchanged: single-cell random-ids, a plain --prompt-file, --logits,
--accuracy with one prompt, matrix mode and every argv-validation path
produce identical output, stderr included. The only --help delta is the
--prompt-file paragraph.

usage text and README.md now state that splitting is --accuracy-only and
why.

Signed-off-by: RemiliaForever <remilia@koumakan.cc>
@RemiliaForever
RemiliaForever (RemiliaForever) merged commit c2f2d64 into main Aug 28, 2026
49 of 52 checks passed
@RemiliaForever
RemiliaForever (RemiliaForever) deleted the refactor/split-bench-modules branch August 28, 2026 08:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants