Skip to content

perf(cli): cut 221ms and 44MB RSS off every CLI invocation - #4463

Draft
Daksh (sudodaksh) wants to merge 2 commits into
nextfrom
claude/cli-binary-performance-c7xicz
Draft

perf(cli): cut 221ms and 44MB RSS off every CLI invocation#4463
Daksh (sudodaksh) wants to merge 2 commits into
nextfrom
claude/cli-binary-performance-c7xicz

Conversation

@sudodaksh

@sudodaksh Daksh (sudodaksh) commented Sep 12, 2026

Copy link
Copy Markdown
Collaborator

Summary

composio --version: 970ms to 749ms. Peak RSS: 175.8MB to 132.2MB. Binary: 96MB to 86MB. No new dependencies (one unused one removed), no API changes.

The compiled binary carried two copies of the TypeScript compiler and all six tiktoken rank tables. A compiled Bun binary parses everything it embeds before running any JS, so every command paid for that.

First PR in a stack of five. Review order: this, #4464, #4468, #4469, #4475.

Bun 1.4.1+4661e494f, linux-x64, best of 7, analytics disabled:

before after
composio --version 970ms 749ms
peak RSS 175.8MB 132.2MB
compiled binary 96MB 86MB
bundle 31.24MB 15.84MB

Changes

  1. run.cmd.ts imported ts from ts-morph, which bundles its own TypeScript. It now uses the typescript package the generation code already pulls in. One compiler instead of two, 8.5MB each.
  2. js-tiktoken's main entry inlines six rank tables (5.3MB). The CLI only uses o200k. Switched to js-tiktoken/lite with that one table. Token ids are identical.
  3. prepareExecuteOutput built the rank table on every successful execute just to compare against a 10,000 token threshold. A token covers at least one byte, so a payload under 10,000 bytes cannot exceed 10,000 tokens. It checks bytes first and only builds the tokenizer past that. It also checks the invocation origin before building it, since composio run always prints inline, and a stored response is encoded once rather than once for the threshold and again for tokenCount.
  4. ToolsExecutorLive resolved a client via clientSingleton.get() (disk reads) and then discarded it, since every caller passes one in. Resolved lazily now.
  5. ts-morph is removed from the CLI's dependencies. Nothing imports it after change 1, and leaving it listed made it easy to bring its TypeScript copy back.

What changes in behavior:

  • Responses under 10KB no longer reach Tiktoken.encode(), so the <|endoftext|> crash stops happening for them. Larger responses still hit it. fix(cli): stop tiktoken special-token literals from failing execute #4464 is the real fix.
  • Executes started by composio run no longer build the tokenizer for responses over 10KB. Their output was always printed inline, so the count was discarded.
  • TypeScript 6.0.2 (ts-morph's copy) becomes 6.0.3.
  • Under COMPOSIO_LOG_LEVEL=Debug, ProjectContext's "resolved from" lines no longer print on the remote execute path.

On change 3: an earlier version of this description said it saved ~500ms per execute, measured in isolation under a different Bun. Inside the compiled binary, new Tiktoken(o200k) costs ~390ms and encode() of 7.5KB about 4ms. Measured end to end on a real HACKERNEWS_GET_ITEM_WITH_ID execute with COMPOSIO_PERF_DEBUG=1, the time from execute.tool_call.end to exit drops from ~300ms to ~15ms for responses under 10KB, and stays ~350ms above it.

Type of change

  • Bug fix
  • New feature
  • Refactor/Chore
  • Documentation
  • Breaking change

It removes a crash, but by accident, so it is not marked as a bug fix.

How Has This Been Tested?

Bun 1.4.1+4661e494f, Node 24.17.0, pnpm 11.8.0, linux-x64.

  1. cd ts/packages/cli && pnpm run typecheck && pnpm run validate:boundaries
  2. pnpm exec vitest run test/src/commands/tools/tools.execute.cmd.test.ts test/src/commands/run.cmd.test.ts: 90 passed. A new case covers a ~18KB response that encodes to ~4k tokens, past the byte check but under the threshold, and asserts it stays inline.
  3. pnpm build:binary && time ./dist/composio --version

Checked but not committed: the three parse helpers give identical output under both compilers across 20 sources (TSX, decorators, using, satisfies, import attributes, unicode). Lite tiktoken gives identical token id streams on 8 samples including CJK, RTL, emoji and control characters. Max tokens per byte was 0.846, under the 1.0 the byte check needs.

Screenshots (if applicable)

Not applicable.

Checklist

  • I have read the Code of Conduct and this PR adheres to it
  • I ran linters/tests locally and they passed
  • I updated documentation as needed
  • I added tests or explain why not applicable
  • I added a changeset if this change affects published packages

No docs describe the bundle contents or the token threshold. The byte pre-filter boundary has a test. The compiler and tokenizer comparisons above are still not committed and should become a suite. @composio/cli is private: true, so no changeset.

Additional context

Bun 1.4.2 gives no gain over 1.4.1 (three rounds of best of 7: 723/732/756ms vs 744/713/747ms). Keep the pin.

Not touched: ~1.1s of execute preflight (5 to 7 serial round trips; project/resolve has no cache and can fire three times), the two-round-trip session create plus execute, and --skip-checks, which currently skips nothing measurable.

🤖 Generated with Claude Code

https://claude.ai/code/session_01EzaE7oGVgziJ5nRvBhcci2

@vercel

vercel Bot commented Sep 12, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
docs Ready Ready Preview Sep 14, 2026 12:10pm UTC

Request Review

@github-actions

github-actions Bot commented Sep 12, 2026

Copy link
Copy Markdown
Contributor

Preview this PR's installer

The hermetic install E2E suite passed for this commit. These commands run the PR's installer scripts against the latest published CLI release — they preview installer behavior, not unreleased binaries.

curl -fsSL "https://raw.githubusercontent.com/ComposioHQ/composio/f0872d039d719df08e0ec960a62c044ec02597b3/install.sh" | sh
curl -fsSL "https://raw.githubusercontent.com/ComposioHQ/composio/f0872d039d719df08e0ec960a62c044ec02597b3/install.sh" | COMPOSIO_INSTALL_SHELL=none sh

The first command is the default flow: it installs the CLI and configures your shell automatically. The second installs only, without touching shell startup files.

Shell-specific setup routes (the override points the variant at this PR's base installer):

curl -fsSL "https://raw.githubusercontent.com/ComposioHQ/composio/f0872d039d719df08e0ec960a62c044ec02597b3/install/bash.sh" | COMPOSIO_INSTALL_SCRIPT_URL="https://raw.githubusercontent.com/ComposioHQ/composio/f0872d039d719df08e0ec960a62c044ec02597b3/install.sh" sh
curl -fsSL "https://raw.githubusercontent.com/ComposioHQ/composio/f0872d039d719df08e0ec960a62c044ec02597b3/install/zsh.sh" | COMPOSIO_INSTALL_SCRIPT_URL="https://raw.githubusercontent.com/ComposioHQ/composio/f0872d039d719df08e0ec960a62c044ec02597b3/install.sh" sh
curl -fsSL "https://raw.githubusercontent.com/ComposioHQ/composio/f0872d039d719df08e0ec960a62c044ec02597b3/install/fish.sh" | COMPOSIO_INSTALL_SCRIPT_URL="https://raw.githubusercontent.com/ComposioHQ/composio/f0872d039d719df08e0ec960a62c044ec02597b3/install.sh" sh

A compiled Bun binary parses its whole embedded module graph before the
first line of JS runs, so bundled-but-unused code is paid for on every
invocation. Verified: a binary that bundles everything but evaluates only
console.log still costs ~235ms, against 15ms for a hello-world build.
Most of this bundle was code `composio execute` never reaches.

Measured on the pinned toolchain (Bun 1.4.1+4661e494f, linux-x64,
best of 7, analytics disabled):

  composio --version   970ms   -> 749ms    (-221ms)
  peak RSS             175.8MB -> 132.2MB  (-43.6MB)
  compiled binary      96MB    -> 86MB
  bundle               31.24MB -> 15.84MB

Four changes.

run.cmd.ts imported `ts` from ts-morph, which vendors its own copy of the
TypeScript compiler, so the binary carried two of them. The file uses only
createSourceFile, forEachChild, ScriptTarget, ScriptKind and five isX
guards, all available in the typescript copy that
src/generation/typescript/* already pulls in. Sharing one compiler also
means commands/generate and commands/run.cmd no longer evaluate a compiler
each.

js-tiktoken's main entry statically inlines all six BPE rank tables (gpt2,
r50k, p50k, p50k_edit, cl100k, o200k) as string literals; the CLI only ever
uses o200k, via encodingForModel('gpt-4o'). The lite build with that single
table produces identical token-id streams.

prepareExecuteOutput built the o200k rank table on every successful
execute, purely to compare the response against a 10k-token threshold.
Constructing it measured ~390ms in a compiled binary on the pinned
toolchain (390.1, 367.1, 418.6ms across three runs), against ~4ms to
encode a 7.5KB payload once the table exists. A BPE token always covers at
least one UTF-8 byte, so a payload of at most THRESHOLD bytes can never
exceed THRESHOLD tokens; checking byte length first reaches the same
decision without the tokenizer.

That ~390ms is construction cost measured in isolation, not an end-to-end
delta on a real `composio execute`. No credentialed run was available to
measure the whole command before and after, so treat it as the size of the
work removed from the success path rather than a verified wall-clock
saving. `COMPOSIO_PERF_DEBUG=1` reports the gap between
`execute.tool_call.end` and exit for anyone able to run it for real.

ToolsExecutorLive resolved a client through clientSingleton.get()
unconditionally, walking the project context off disk, then discarded it
because every caller on the remote-execute path passes one in.

Three behavioral deltas, none of them the tokenization result or the
inline/file decision:

1. Tiktoken.encode() throws on the literals <|endoftext|> and
   <|endofprompt|> appearing anywhere in the response, at any size (a
   66-byte payload reproduces it). That throw landed after
   "Execution successful" had printed, so the tool ran and the CLI still
   exited 1 with nothing on stdout. Responses at or under 10KB no longer
   reach encode(), so they now succeed. Larger responses still hit it;
   the real fix is passing allowedSpecial 'all' and is not in this commit.
2. TypeScript 6.0.2 (ts-morph's vendored copy) to 6.0.3. Differential
   tested: the three real parse helpers over 20 sources covering TSX,
   decorators, `using`, `satisfies`, import attributes, optional-chained
   calls and unicode gave identical output on all 60 comparisons.
3. Under COMPOSIO_LOG_LEVEL=Debug, ProjectContext's "resolved from ..."
   debug lines no longer appear on the remote-execute path. The local-tool
   path still calls get() and is unchanged.

Verified with typecheck:src, oxlint, validate:boundaries, the
tools.execute and run command suites (89 tests), and differential tests of
both tokenizers and both TypeScript versions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EzaE7oGVgziJ5nRvBhcci2
…s once

Check the invocation origin before building the tokenizer, since composio run
always prints inline, and pass the token count from the threshold check into
the stored-output summary instead of encoding the payload twice.

Add a test for a response past the byte pre-filter but under the token
threshold, and drop the unused ts-morph dependency.
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.

3 participants