Skip to content

feat(bench): measure against clap at mise's scale - #825

Merged
jdx merged 5 commits into
agent/gen-shadowfrom
agent/gate-clap
Aug 12, 2026
Merged

jdx merged 5 commits into
agent/gen-shadowfrom
agent/gate-clap

Conversation

@jdx

@jdx jdx commented Aug 12, 2026

Copy link
Copy Markdown
Owner

Stacked on #824. This is the gate — the measurement the experiment was supposed to be judged by.

The result

At mise's full scale (211 commands, 711 flags, 128 arguments, four levels), parsing mise use -g node@20:

usage clap ratio
instructions, cold parse 50,885 5,265,233 103×
instructions, warm parse 47,223 5,351,127 113×
wall clock 2.1µs 440µs 214×

clap's 440µs decomposes into 283µs building its command tree, ~135µs validating it, and ~23µs actually parsing — so even against clap's parse alone, with the tree already built and paid for, this is 11× faster. The tree is the thing this project exists to delete, and it is 64% of clap's cost.

How it is measured, and why that way

gen-shadow … clap emits the same CLI in clap's vocabulary from the same spec and the same traversal as the usage shadow, so this compares two parsers rather than two transcriptions. Both sides drop the same properties (aliases, mounts, default_subcommand, second long forms), and the generator counts them.

Three release binaries — one per parser, plus one that does everything except parse. The third is what makes the numbers mean anything: roughly two thirds of a small binary's instructions are the dynamic loader and libc starting up, and neither parser is responsible for those. Whole processes rather than a criterion loop with the tree hoisted out, because clap builds its command tree on the way to parsing and that is precisely the cost in question.

tak gates all three in CI, so a regression on either side shows up as a diff in the series.

Methodology, and a correction

The first version of this PR reported 48–58× and ~110µs. Both were wrong in the same way: they subtracted a baseline measured in a separate no-op binary. Two binaries do measurably different amounts of setup before main, and that difference was landing in whatever I attributed to parsing — it cost usage a factor of two and a half.

parse-n and parse-n-clap take a repeat count from the environment, so N=1 minus N=0 is a cold parse in a fresh process and N=2 minus N=1 is a warm one, with the binary held fixed. time-parse times the parse in-process, which is what the 2.1µs figure is.

I also checked whether the instruction count was really relocation work, since 211 commands' worth of statics hold thousands of pointers between them: linking the tables costs nothing measurable. A binary that reads one static and never parses matches one with no tables at all.

Both gating targets are met — under 100k instructions, under 50µs — and not narrowly. The one number still owed is allocations on the derive path: usage-argv's own are asserted at zero, but the derive allocates a String per value and nothing counts them yet.

One fidelity note, since it cuts against this parser rather than for it: neither shadow carries mise's mounts or default_subcommand, so both are slightly cheaper than the real thing. And clap keeps one default on a collecting flag that the usage derive refuses, so clap answers a hair more grammar there.

AI-assisted — Tool: Claude Code; model: anthropic/claude-opus-5; version: unavailable.


Note

Low Risk
Benchmark harness and plan updates only; no production parser, auth, or data-path changes.

Overview
Completes the performance gate by adding a clap-equivalent shadow of mise and binaries that measure both parsers the same way.

gen-shadow … clap already emits shadow-mise-clap from the same spec/traversal as the usage shadow. This wires it into gate and adds parse-clap, parse-n / parse-n-clap, and time-parse so cold/warm instruction counts and wall time can be differenced within one binary (avoiding the earlier separate no-op baseline error).

PLAN.md records the result: ~51k vs ~6M instructions (~117×) and ~2µs vs ~500µs wall clock, meeting both gating targets.

Reviewed by Cursor Bugbot for commit 4564106. Bugbot is set up for automated code reviews on this repo. Configure here.

@coderabbitai

coderabbitai Bot commented Aug 12, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Central YAML (base), Organization UI (inherited)

Review profile: CHILL

Plan: Pro Plus

Run ID: 4b7c9131-e6bc-4621-9509-66d6f7812e89

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@greptile-apps

greptile-apps Bot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

The PR adds a generated clap shadow of mise’s CLI and benchmark binaries for comparing clap with the compiled usage parser.

  • Extends xtask gen-shadow with usage and clap output dialects.
  • Adds instruction-count and in-process timing harnesses.
  • Updates benchmark configuration, generated shadows, and the performance report in PLAN.md.

Confidence Score: 5/5

The PR appears safe to merge because no blocking failure remains in the eligible follow-up review scope.

No blocking failure remains.

Important Files Changed

Filename Overview
xtask/src/shadow.rs Adds shared traversal and dialect-specific generation for equivalent usage and clap shadow declarations.
benches/gate/src/bin/parse-n.rs Adds an environment-controlled repeated-parse binary for same-process-image instruction differencing.
benches/gate/src/bin/parse-n-clap.rs Adds the corresponding repeated-parse binary for the generated clap shadow.
benches/gate/src/bin/time-parse.rs Adds in-process timing for usage parsing, clap tree construction, and clap parsing with a reused tree.
tak.toml Adds recorded benchmark entries for the baseline, usage shadow, and clap shadow processes.
PLAN.md Documents the benchmark methodology, corrected measurements, and remaining allocation measurement.

Reviews (5): Last reviewed commit: "chore(bench): regenerate the clap shadow..." | Re-trigger Greptile

jdx commented Aug 12, 2026

Copy link
Copy Markdown
Owner Author

Correcting my own numbers, since they moved in my favour and that is exactly when to show the work.

The table first published here reported 48–58× and ~110µs of wall clock. Both were measured by subtracting a separate no-op binary from the parsing one. That looked equivalent and was not: two binaries do measurably different amounts of setup before main, and the difference had been landing in whatever I attributed to parsing.

Differencing two runs of the same binary over how many parses it performs holds everything else fixed:

usage clap ratio
instructions, cold parse 50,885 5,265,233 103×
instructions, warm parse 47,223 5,351,127 113×
wall clock, in-process 2.1µs 440µs 214×

And clap's 440µs decomposes: 283µs building the command tree, ~135µs validating it, ~23µs parsing. Against clap's parse alone — tree already built — this is still 11× faster.

Two things I checked rather than assumed:

  • Is the instruction count relocation work? 211 commands' worth of statics hold thousands of pointers between them, so a PIE binary could be paying at load time. It is not: a binary that reads one static and never parses matches one with no tables at all.
  • Do 47k instructions and 2.1µs agree? They imply about 5 instructions per cycle, which is high but within what a wide modern core sustains on a tight, L1-resident, perfectly-predicted loop. The cold figure (50.9k, one parse in a fresh process) is the one a CLI actually pays.

AI-assisted — Tool: Claude Code; model: anthropic/claude-opus-5; version: unavailable.

@jdx
jdx force-pushed the agent/gate-clap branch from 1fcedce to 4f6b429 Compare August 12, 2026 11:06
@github-actions

github-actions Bot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Instruction counts

benchmark trend instructions Δ wall (min) Δ
markdown ▁▁▁▁▁▁▁█████████ 148,242,482 → 148,265,798 +0.02% 13.93 → 13.71ms -1.61%
startup ▄▄▄▄▄▄▄█████▁▁▁▅ 1,199,527 → 1,201,965 +0.20% 0.95 → 0.94ms -0.53%

No instruction-count regression above 1%.

New, nothing to compare against: gate-baseline on bamboo-v2-ubuntu24.04-x64-30vcpu-24gb-rust1.97.1, gate-clap on bamboo-v2-ubuntu24.04-x64-30vcpu-24gb-rust1.97.1, gate-usage on bamboo-v2-ubuntu24.04-x64-30vcpu-24gb-rust1.97.1

Only instruction counts gate. Wall clock is shown for context — on identical hardware it moves 4-20% run to run.

Measured by tak — instruction-counted CLI benchmarks, stored in this repository's git notes.

4564106901bb vs 7047ae1232b2 · measured on the runner, not pushed to the history.

@jdx
jdx force-pushed the agent/gate-clap branch from 4f6b429 to 0b2aad8 Compare August 12, 2026 12:12
jdx added 5 commits August 12, 2026 12:22
`gen-shadow … clap` writes the same CLI in clap's vocabulary, from the same spec and
the same traversal that writes the usage one, so what is being compared is two parsers
rather than two people's transcriptions. Both shadows drop the same properties, and
the generator says which.

At mise's full scale — 211 commands, 711 flags, four levels deep — parsing one command
line costs 99k–127k instructions against clap's 5.8M–6.6M. Between 48× and 58× fewer,
near constant across invocation shapes, because there is no tree to build. Wall clock
by process delta is about 110µs against 1.3ms.

Three release binaries and one measurement: each parser, plus one that does everything
except parse. The third is what makes the numbers mean anything — roughly two thirds
of a small binary's instructions are the dynamic loader and libc starting up, and
neither parser is responsible for those. Whole processes rather than a loop with the
tree hoisted out, since clap builds its command tree on the way to parsing and that is
the cost in question.

`tak` gates all three in CI. The absolute targets are a separate question from the
ratio and PLAN.md now says so: instructions land at or a little over the 100k target,
and a difference between two processes is too coarse a ruler for a 50µs claim. Timing
in-process and counting allocations on the derive path are what remain.
The numbers in this PR were measured by subtracting a separate no-op binary from the
parsing one, which looked equivalent and was not: two binaries do measurably different
amounts of setup before `main`, and that difference had been landing in whatever was
attributed to parsing. It cost usage a factor of two and a half.

Differencing two runs of the *same* binary over how many parses it does holds
everything else fixed. `parse-n` and `parse-n-clap` take the count from the
environment, so N=1 minus N=0 is a cold parse in a fresh process and N=2 minus N=1 is
a warm one.

Corrected, at mise's full scale: a cold parse costs 50.9k instructions against clap's
5.27M — 103× rather than the 48–58× first reported — and `time-parse` puts the wall
clock at 2.1µs against 440µs. clap's 440µs is 283µs building its command tree, ~135µs
validating it and ~23µs parsing, so even against its parse alone this is 11× faster.

Also checked, since 211 commands' worth of statics hold thousands of pointers between
them: linking the tables costs nothing measurable. A binary that reads one static and
never parses matches one with no tables at all, so none of this is relocation work
being counted as parsing.
The comparison is only fair if the two shadows say the same things about the same
spec, in each framework's own words. A test now renders one flag both ways and checks
each claim survives the translation — the long form, `global`, `env`, and the choices,
which clap spells as a `PossibleValuesParser` — along with the one difference that is
deliberate: clap's derive reads a field's type as written, so its shadow cannot use the
absolute paths the usage shadow does.
Both shadows changed: 27 of mise's commands now declare a required subcommand, which
clap builds more tree for, and 467 lines of duplicated long help came out of each.

Cold parse, mise's full scale: 50.9k instructions against clap's 5.96M — 117× — and
2.0µs against 500µs. clap's 500µs is 315µs of tree construction, ~160µs of validation
and ~24µs of parsing.
@jdx
jdx force-pushed the agent/gate-clap branch from 0b2aad8 to 4564106 Compare August 12, 2026 12:25
@jdx
jdx merged commit 2b39860 into main Aug 12, 2026
9 checks passed
@jdx
jdx deleted the agent/gate-clap branch August 12, 2026 13:57
jdx added a commit that referenced this pull request Aug 12, 2026
Stacked on #825. Settles the question left open in #823, the way you
called it.

## What was open

`var_max` had two coherent readings, and the two implementations had
each quietly picked one. For `arg "[a]" var=#true var_max=1` then `arg
"[b]"`, given `ex x y`:

| | result |
| --- | --- |
| usage-argv (before) | `a = ["x","y"]`, then `var_too_many` |
| usage-lib | `a = ["x"]`, `b = "y"` |
| clap (`num_args = 0..=1`) | same as usage-lib |

## The decision

**A limit, not a check.** clap behaves that way, every spec in the fleet
is generated from a clap command, and it is the only reading under which
`[a]… [b]` can be filled at all.

So `var_max` moves into the hot `Arg`/`Flag` tables — the ones kept
deliberately free of anything but what binding needs — as `Option<u32>`,
and the parser counts what a variadic has taken. `var_min` stays a
post-binding check, since no single word tells you a variadic will end
up short. `var_too_many` accordingly stops being reachable for a bounded
variadic and now describes only a repeatable flag's occurrences.

**The cost: 55 instructions per parse, 0.1%.** Wall clock unchanged at
2.0µs. That was the thing worth checking, since the objection to this
reading was that it puts a validation concept in the binder.

## What follows from it

- **The derive's rule relaxes.** An argument after an *unbounded*
variadic is still refused; after a bounded one it is allowed, and a
bounded variadic behind a `--` no longer spends the separator. The error
message now names both things that stop a variadic, so it points at a
fix rather than just a refusal.
- **Four corpus vectors**, at the **binding** layer where the question
now lives: the bound hands over, the bound with nothing after it is an
`unexpected_arg` rather than a silent drop, unbounded still takes
everything, and the flag form.
- **One divergence recorded rather than fixed**: for a flag whose single
occurrence collects, usage-lib does not collect at all — it takes one
value, so the second word falls through to the positional. That predates
this change (`long-variadic-flag-arg` documents the same gap) and
bounding what usage-argv collects is consistent with it collecting in
the first place.

`docs/spec/argv.md` states the rule, and PLAN.md records the decision
with its reasoning so it does not get re-litigated.

*AI-assisted — Tool: Claude Code; model: anthropic/claude-opus-5;
version: unavailable.*

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Medium Risk**
> Changes core argv binding semantics and parser hot-path behavior;
mistakes could mis-route tokens or break CLIs relying on the old
post-binding `var_max` for variadics, though coverage is broad.
> 
> **Overview**
> Settles **`var_max` as a binding limit**, not a post-parse count
check, so bounded variadics stop collecting and following positionals
can be filled (clap `num_args` / usage-lib behavior).
> 
> **usage-argv** adds `var_max: Option<u32>` on `Flag` and `Arg`, tracks
per-occurrence counts (`collected` / `arg_taken`), resets counts when
advancing positionals or jumping past `--`, and stops variadic flag
collection when the bound is hit.
> 
> **usage-derive** emits `var_max` into hot tables for variadic
args/flags only; compile-time rules allow an argument after a
**bounded** variadic (or after `--`); post-binding `var_too_many`
applies only to **repeatable** flags counting occurrences.
> 
> **Conformance bridge**, corpus vectors, spec docs, and **PLAN.md** are
updated (including revised bench numbers); bounded variadic-flag
behavior vs usage-lib remains a recorded divergence.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
285503b. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Added maximum value limits for variadic flags and positional
arguments.
* Bounded variadic arguments can pass remaining values to subsequent
arguments.
  * Added conflict detection for incompatible flags.
  * Documented `var_max` behavior and clarified validation rules.

* **Bug Fixes**
* Corrected variadic value collection, overflow handling, inline values,
and parser state resets.
  * Preserved minimum-value validation after parsing.
  * Improved handling around separators and repeatable flag occurrences.

* **Tests**
* Expanded coverage for bounded and unbounded variadics, positional
fallthrough, overflow, and double-dash scenarios.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
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.

1 participant