Skip to content

perf: shrink the help and completion code usage-rs adds to a binary - #1469

Merged
jdx merged 1 commit into
mainfrom
claude/bundle-size-reduction-402a4d
Sep 23, 2026
Merged

jdx merged 1 commit into
mainfrom
claude/bundle-size-reduction-402a4d

Conversation

@jdx

@jdx jdx commented Sep 23, 2026 •

Copy link
Copy Markdown
Owner

https://entire.io/gh/jdx/usage/trails/21

A CLI built with usage-rs carries its help, completion and diagnostics renderers in its release binary. In oxlint and oxfmt (oxc-project/oxc#26027), that overhead is what reviewers weigh against bpaf. This PR makes those renderers smaller without changing anything they print.

What changed

  • One sort for cold paths. Help rows, completion candidates and "did you mean" suggestions were each sorted with slice::sort_*. That instantiated a full quicksort for six different element types, about 35 KB of code. They now share one bottom-up merge sort over indices behind a dyn comparator (argv/src/order.rs). Each element type only adds a small loop of swaps. The sort is stable where the old one was not, which changes nothing a user sees: every comparator either orders completely or is followed by a dedup of equal items.
  • Non-generic help sections. split_groups_section took iterators and closures generically, so the grouping loop was inlined into all six help-page call sites. It is now a thin generic shell over one #[inline(never)] body.
  • View lookup folds away. view_for_program is #[inline] and returns early when a spec declares no views. For a CLI without views, the derive's view-rewriting branches become dead code and are dropped.

Measured effect

Measured on oxc's PR branch, with its usage crates patched to this branch, using oxc's release profile (opt-level = 3, fat LTO, one codegen unit) on linux x86_64. Numbers are summed allocated sections of the stripped binaries (size), because stripped file sizes only move in 4 KB steps.

oxlint oxfmt
Overhead vs bpaf, usage main +219,300 B +169,148 B
Overhead vs bpaf, this PR +178,268 B +127,916 B
Saved 41,032 B (−19%) 41,232 B (−24%)

Output is unchanged. On both binaries I compared, byte for byte, --help, -h, coloured --help, --version, unknown-flag and invalid-value errors, __usage_spec__ and __complete_word__ for bash, zsh and fish, before and after this change. The workspace suite passes (cargo test --all --all-features, 2,799 tests), with no snapshot changes, and clippy is clean. The perf gate's benchmarks run the usage CLI, which does not go through usage-argv, so they are not expected to move.

What remains

Most of oxlint's remaining overhead is static data rather than code: each FlagMeta is 664 bytes, mostly empty fields. Moving the rarely set fields behind a shared pointer would change the public FlagMeta/CommandMeta fields, so that is left for a separate PR.

🤖 Generated with Claude Code


Note

Low Risk
Binary-size and internal sorting/refactor only; PR asserts byte-identical help, completion, and error output with full test suite passing.

Overview
Shrinks usage-argv help, completion, and diagnostic code in release binaries by deduplicating sorting and help-section layout, without changing rendered output.

Shared stable sort (argv/src/order.rs). Cold-path lists (completer names, completion candidates, “did you mean” scores, help usage rows) no longer call slice::sort_unstable* per element type. They route through one index-based merge sort behind a dyn comparator so LLVM does not monomorphize multiple quicksorts (~35 KB cited in the PR). Sorts become stable; callers that dedup equal keys are unchanged in user-visible behavior.

Help section grouping. split_groups_section is a thin generic wrapper over a single #[inline(never)] grouped_sections that indexes slices instead of cloning iterators—one copy of the grouping loop instead of six inlined variants. Local sort_rows in help.rs is removed in favor of crate::order::sort_by.

View dispatch. view_for_program is #[inline] and returns None immediately when spec.views is empty so CLIs with no executable views can drop dead view-rewriting branches at compile time.

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

Summary by CodeRabbit

  • Improvements
    • Completion candidates, diagnostic suggestions, and help entries now use consistent ordering. Items with equal sort priority retain their original order.
  • Bug Fixes
    • Program-view matching now handles applications with no declared views without attempting view matching.

Share one index-based merge sort across every cold-path list instead of
instantiating std's sort per element type, run the help page's section
grouping through one non-generic body instead of inlining it at six call
sites, and let view lookup fold away for CLIs that declare no views.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Sep 23, 2026 •

Copy link
Copy Markdown

Review in Change Stack →

Navigate logical layers of code changes, visualize relationships, and explore their blast radius.

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

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

Review profile: CHILL

Plan: Advanced

Run ID: fb0312c8-9179-4ec2-adfc-03d81b8602ca

📥 Commits

Reviewing files that changed from the base of the PR and between a7882ac and 8a50714.

📒 Files selected for processing (6)
  • argv/src/complete.rs
  • argv/src/diagnostic.rs
  • argv/src/help.rs
  • argv/src/lib.rs
  • argv/src/order.rs
  • argv/src/spec.rs

Included review availability: Your plan provides up to 4 included reviews per hour; 3 remain after this review.


📝 Walkthrough

Walkthrough

The pull request adds a shared stable sorting utility and applies it to completion, diagnostic, and help code. It also refactors help section grouping and changes spec view lookup when no views are declared.

Changes

Sorting and Help Generation

Layer / File(s) Summary
Shared stable sort
argv/src/order.rs, argv/src/lib.rs
Adds a stable sort based on item indices, applies the resulting permutation, and tests ordering against the standard stable sort. The private module is enabled with the spec feature.
Shared sort call sites
argv/src/complete.rs, argv/src/diagnostic.rs, argv/src/help.rs
Completion candidates, diagnostic matches, and help rows use the shared sort. Help usage lists retain descending-length ordering.
Help section grouping
argv/src/help.rs
Changes grouping to accept slices and use an indexed helper. Short and long help call sites now pass slices.

Spec View Lookup

Layer / File(s) Summary
Empty-views early return
argv/src/spec.rs
view_for_program returns None when Spec.views is empty. Declared-view matching is handled by a private helper.

Priority: ➖ Normal

Estimated code review effort: 3 (Moderate) | ~25 minutes

Change: Refactor

Suggested reviewers: lu-zero

Merge Risk: ⚪ Minimal · up to 8a507

The sorting, help grouping, and empty-view fast path preserve their described behavior. The change is ready to merge.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 57.89% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 19 functions across 6 files. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly identifies the main objective: reducing the binary size overhead from help and completion code. It is concise and related to the reported changes.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
  • Fix all pre-merge checks with AI

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 Sep 23, 2026

Copy link
Copy Markdown
Contributor

RetriggerConfidence Score: 5/5

The PR appears safe to merge with no actionable failures identified.

Summary

This PR reduces generated CLI binary size by consolidating cold-path sorting, sharing help-section grouping code, and allowing view-related branches to be optimized away.

  • Adds a shared stable merge sort over indices for help, completion, and diagnostic ordering.
  • Moves generic help-section traversal behind a non-generic, non-inlined implementation.
  • Adds an inline empty-view fast path while preserving existing view lookup behavior.
  • No actionable correctness, security, or repository-rule violations were identified.

Reviews (1) · Last reviewed commit: "perf: shrink the help and completion cod..."

@jdx
jdx merged commit 7abcdbf into main Sep 23, 2026
13 checks passed
@jdx
jdx deleted the claude/bundle-size-reduction-402a4d branch September 23, 2026 10:48
@github-actions

Copy link
Copy Markdown
Contributor

Instruction counts

benchmark trend instructions Δ wall (min) Δ
markdown ▁▇▇▇▆▆▆▆▆▆▆████ 285,423,199 → 285,469,242 +0.02% 49.47 → 49.90ms +0.87%
startup ▁▁▁▁▁▁▁▁▁▁▁▇▇▇█ 1,054,619 → 1,068,021 +1.27% ⚠️ 1.38 → 1.49ms +7.92%

1 benchmark(s) above the 1% gate: startup +1.27%

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.

Shadow comparison

Parsing mise use -g node@20 against a shadow of mise's committed spec.
Reported, not gated: the shadow grows as the derive learns to express more, so
what to watch is the ratio rather than either column.

framework stripped binary, bytes
usage 1331504
bpaf 2494656
clap 3100904
framework instructions, cold parse vs usage
usage 8508 —
clap 6314907 742x
bpaf 21909296 2575x
                                              min       p01       p10    median
usage-rs: argv -> struct                      773       779       787       799  ns
clap: build tree + parse -> struct        1232392   1234810   1242935   1261124  ns
bpaf: build parser + parse -> struct      3349454   3349454   3414381   3494017  ns

usage: argv -> struct                             817 ns      0.82 µs
clap: build tree + parse -> struct            1264102 ns   1264.10 µs
clap: parse -> struct, tree reused              50859 ns     50.86 µs
clap: build tree only                          748262 ns    748.26 µs

8a50714f9d46 vs 0676af068487 · measured on the runner, not pushed to the history.

jdx added a commit that referenced this pull request Sep 23, 2026
…1470)

<!-- entire-trail-link-start -->
https://entire.io/gh/jdx/usage/trails/22
<!-- entire-trail-link-end -->

Every flag in a usage-rs CLI gets a static `FlagMeta` record in the
binary. Before this change it was 664 bytes: 58 fields, nearly all empty
for a typical flag, which usually declares only help, a heading and
maybe a default or choices. With oxlint's 47 flags plus the built-in
help/version entries, that was ~35 KB of mostly zeros. After
[#1469](#1469), it was the largest
remaining part of what usage-rs adds to oxlint and oxfmt
([oxc-project/oxc#26027](oxc-project/oxc#26027)).

### What changed

The 35 rarely declared fields move into a new `FlagExtra` struct behind
`FlagMeta::extra`. They cover:

- relations: `conflicts`, `requires*`, `required_if*`,
`required_unless*`, `overrides`, `exclusive`, `default_if`;
- deprecation, including `deprecated_env`;
- validation;
- value bounds and `delimiter`;
- hidden aliases, `display_order`, `admonitions`, `value_names`,
`env_fallback`, `hide_env_values`;
- spec-only metadata: `choice_aliases`, `choice_details`,
`complete_type`, `surface`, `available_if`, `effect`.

`FlagMeta` itself is now 168 bytes.

A flag that declares none of these points at one shared static,
`NO_FLAG_EXTRA`. The derive can't always tell at expansion time whether
a flag's extras are empty, because choice aliases can come from a
`ValueEnum`'s trait constants. So it builds each flag's extras as a
`const`, and the `const fn` `FlagExtra::shared` swaps an empty one for
the shared static at compile time. `shared` destructures every field
without `..`, so adding a field to `FlagExtra` without updating the
check is a compile error, not silently dropped metadata.

### For code that uses these types directly

Derived CLIs need no changes. Code that reads a moved field goes through
`extra`:

```rust
// before
if let Some(reason) = meta.deprecated { /* … */ }
// after
if let Some(reason) = meta.extra.deprecated { /* … */ }
```

Hand-written tables set the moved fields in a nested literal. In a
`static` the reference is promoted, as with the table's other slices:

```rust
FlagMeta {
    flag: &OLD,
    help: Some("Use the old mode"),
    extra: &FlagExtra {
        deprecated: Some("use --new"),
        ..FlagExtra::EMPTY
    },
    ..FlagMeta::EMPTY
}
```

`FlagMeta::EMPTY` already points at the shared instance, so
`..FlagMeta::EMPTY` keeps working for flags with no extras.

### Measured effect

Measured on oxc's PR branch, with its usage crates patched to this
branch, using oxc's release profile (`opt-level = 3`, fat LTO) on linux
x86_64. Numbers are summed allocated sections of the stripped binaries,
compared with #1469 alone:

| | oxlint | oxfmt |
| --- | ---: | ---: |
| `.data.rel.ro` | −42,624 B | −14,880 B |
| `.rela.dyn` (one pointer per flag) | +1,992 B | +624 B |
| `.text` + `.rodata` | +736 B | +624 B |
| **Total** | **−39,896 B** | **−13,632 B** |

Together with #1469, usage-rs's overhead over bpaf drops from +219,300 B
to +138,372 B on oxlint (−37%) and from +169,148 B to +114,284 B on
oxfmt (−32%).

Output is unchanged. On both oxc binaries, I compared help (plain and
coloured), `--version`, error messages, `__usage_spec__` and completions
before and after, and all 24 cases were byte-identical. The workspace
suite passes (2,800 tests), with no snapshot changes, and clippy is
clean. A new test pins that empty extras resolve to the shared instance
and that anything declared, even a lone `bool`, keeps its own copy.

`ArgMeta` and `CommandMeta` keep their layout. oxlint has one positional
and ten command records, so the same split would save a few KB at most.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Medium Risk**
> Wide refactor of flag metadata layout and every consumer path (help,
completion, spec emission, derive); behavior should be identical but any
missed `extra.` migration would be a subtle runtime bug.
> 
> **Overview**
> **Shrinks per-flag static metadata** by moving ~35 rarely set fields
(relations, deprecation, validation, hidden aliases, spec-only
choice/completion data, effects, etc.) from inline `FlagMeta` into a new
`FlagExtra`, referenced by `FlagMeta::extra`. Common flags with no
extras point at a single shared `NO_FLAG_EXTRA`; derive and table
builders construct a per-flag `FlagExtra` and run `FlagExtra::shared` so
empty extras collapse to that static at compile time.
> 
> **Call sites** in completion, help rendering, KDL/spec emission,
conformance table building, and tests now read those fields through
`meta.extra.*` instead of on `FlagMeta` directly. `help_heading` stays
on `FlagMeta`. Hand-written `FlagMeta` literals nest an `extra:
&FlagExtra { … }` block; generated CLIs are unchanged at the source
level.
> 
> No intended behavior change—same help, completions, and spec
output—with the goal of much smaller `.data.rel.ro` in large CLIs.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
e3c76b6. 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

* **Refactor**
* Command-line help, completion, and flag behavior remain unchanged.
Existing flag details, such as help text, choices, visibility, and
deprecation notices, continue to appear as before.
* No new end-user features or behavior changes are included in this
update.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
jdx pushed a commit that referenced this pull request Sep 23, 2026
<!-- entire-trail-link-start -->
https://entire.io/gh/jdx/usage/trails/16
<!-- entire-trail-link-end -->

### 🐛 Bug Fixes

- **(help)** colour only the structure the renderer writes by
[@jdx](https://github.com/jdx) in
[#1480](#1480)

### ⚡ Performance

- shrink the help and completion code usage-rs adds to a binary by
[@jdx](https://github.com/jdx) in
[#1469](#1469)
- keep rarely declared flag metadata out of every flag's table by
[@jdx](https://github.com/jdx) in
[#1470](#1470)
- write help rows through one renderer for both help pages by
[@jdx](https://github.com/jdx) in
[#1471](#1471)
- collect coloured-help structure while the page is written by
[@jdx](https://github.com/jdx) in
[#1476](#1476)
- shrink the per-CLI code the derive generates for binding by
[@jdx](https://github.com/jdx) in
[#1477](#1477)
- shrink the parse-error renderer usage-rs adds to a binary by
[@jdx](https://github.com/jdx) in
[#1478](#1478)
- shrink the shell-completion code a derived CLI carries by
[@jdx](https://github.com/jdx) in
[#1475](#1475)
- keep rarely declared command metadata out of every command's table by
[@jdx](https://github.com/jdx) in
[#1472](#1472)
- shrink the command and metadata tables a derive emits by
[@jdx](https://github.com/jdx) in
[#1474](#1474)

### 🔍 Other Changes

- **(ci)** comment on a discussion when the PR implementing it merges by
[@jdx](https://github.com/jdx) in
[#1460](#1460)

### 📦️ Dependency Updates

- update jdx/packslip action to v1.2.0 by
[@renovate[bot]](https://github.com/renovate[bot]) in
[#1466](#1466)
- update zizmorcore/zizmor-action action to v0.6.4 by
[@renovate[bot]](https://github.com/renovate[bot]) in
[#1465](#1465)
- update module github.com/urfave/cli/v3 to v3.12.0 by
[@renovate[bot]](https://github.com/renovate[bot]) in
[#1467](#1467)
- update aube to v2.3.0 by [@jdx](https://github.com/jdx) in
[#1473](#1473)
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