feat(bench): measure against clap at mise's scale - #825
Conversation
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Central YAML (base), Organization UI (inherited) Review profile: CHILL Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
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. Comment |
Greptile SummaryThe PR adds a generated clap shadow of mise’s CLI and benchmark binaries for comparing clap with the compiled usage parser.
Confidence Score: 5/5The PR appears safe to merge because no blocking failure remains in the eligible follow-up review scope. No blocking failure remains. Important Files Changed
Reviews (5): Last reviewed commit: "chore(bench): regenerate the clap shadow..." | Re-trigger Greptile |
|
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 Differencing two runs of the same binary over how many parses it performs holds everything else fixed:
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:
AI-assisted — Tool: Claude Code; model: anthropic/claude-opus-5; version: unavailable. |
Instruction counts
No instruction-count regression above 1%. New, nothing to compare against: 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.
|
`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.
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 -->
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: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 … clapemits 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.
takgates 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-nandparse-n-claptake 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-parsetimes 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
Stringper 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 … clapalready emitsshadow-mise-clapfrom the same spec/traversal as the usage shadow. This wires it intogateand addsparse-clap,parse-n/parse-n-clap, andtime-parseso 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.