Skip to content

Perf/cli startup - #41

Merged
llambeau merged 4 commits into
masterfrom
perf/cli-startup
Aug 3, 2026
Merged

Perf/cli startup#41
llambeau merged 4 commits into
masterfrom
perf/cli-startup

Conversation

@llambeau

@llambeau llambeau commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

No description provided.

llambeau and others added 4 commits August 3, 2026 12:16
emb ps takes ~1.7s to wrap a ~0.45s docker compose ps call, and profiling
attributes 86% of that to compiling JavaScript rather than running it.
Optimising this needs measurement: the machine has ~20% run-to-run variance,
and several plausible-sounding fixes turned out to be worth nothing.

  npm run bench:startup [dir]              wall-clock ladder
  npm run bench:modules -- <cmd> [--cwd]   modules compiled, bytes parsed

The ladder isolates each stage (node floor, oclif bootstrap, config+monorepo
init, docker subprocess) so a change can be attributed to a layer. Timing is
amortized over batches and reported as a median, since single runs on a loaded
machine are unusable.

The module counter instruments both loader pipelines. A CJS-only count is
badly misleading here: it reports 836 modules / 4.3 MB for emb ps, where the
true figure is 2116 / 21.2 MB. The gap is @kubernetes/client-node, which is
ESM, and which alone accounts for two thirds of everything emb ps parses.
ESM is counted via a module.register() hook reporting over a MessagePort, and
deduplicated against the CJS hook since a CommonJS file imported from ESM
passes through both. register() must be installed from a `node --import`
preload; during a --require preload the ESM loader is not yet initialized and
it fails with a misleading "Missing internal module" error.

Both scripts warn when oclif.manifest.json is absent. Without one, oclif loads
every command module at startup to read its metadata, which inflates
emb --version roughly 5x and describes a configuration no user runs. The
manifest is also now gitignored — prepack generates it and postpack removes
it, so it should never be committed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
src/index.ts re-exports everything, so importing from '@' makes the entire
codebase reachable from any entry point. That is why emb ps parses 21 MB of
JavaScript to shell out to docker compose ps.

26 of the 38 files importing '@' needed only getContext (or getContext plus
setContext, or plus the EmbContext type). All of those live in src/context.ts,
which has no runtime dependencies at all. Import them from there instead.
AbstractOperation also takes an explicit `import type` for EmbContext.

12 root-barrel imports remain; they pull real values (Monorepo, ResourceInfo,
CommandExecError) and need untangling separately.

No change to module count (2116) or wall-clock — expected, since the graph
stays fully connected until the last edge into each subtree is cut. The gain
is structural, and visible in the trace. The route from ps to Kubernetes was

  ps.js -> docker/index.js -> docker/resources/index.js
        -> DockerImageResource.js -> monorepo/index.js
        -> monorepo/operations/index.js -> RunTasksOperation.js
        -> kubernetes/operations/index.js -> @kubernetes/client-node

and is now

  ps.js -> cli/index.js -> cli/abstract/index.js -> BaseCommand.js
        -> kubernetes/client.js -> @kubernetes/client-node

That whole barrel route is severed, leaving a single edge — the eager
createKubernetesClient() call in BaseCommand.init() — between emb ps and
14.5 MB of Kubernetes ESM. Cutting it is now the highest-value remaining fix.

678/678 unit tests pass; adds no new lint problems (34 pre-exist on master).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@kubernetes/client-node is 826 files and 14.5MB of ESM — two thirds of
everything emb ps had to parse. BaseCommand.init() built a client on every
command, and RunTasksOperation imported the Kubernetes operations statically
for a branch only its `kubernetes` executor reaches.

EmbContext.kubernetes is now optional and populated on first use via
getKubernetesClient(), which memoises on the context so a client injected by a
test or a plugin wins over building a real one. The seven call sites that read
context.kubernetes now await it; all were already async. RunTasksOperation
imports the Kubernetes operations inside runKubernetes().

  emb ps    1683ms -> 1125ms   (-33%)
  emb tasks 1267ms ->  922ms
  modules   2116 / 21.20MB -> 1171 / 5.30MB   (-75% source parsed)

The import in createKubernetesClient() is dynamic, so the accessor is async.
require(esm) does work for this package on Node 22.22 and would have allowed a
synchronous accessor with no call-site changes, but it only landed unflagged in
22.12 while engines allows >=22.0.0.

Two notes for whoever picks up the remaining phases:

Phase 3 alone changed nothing — 2116 modules before and after. A second route
to the SDK was still live through RunTasksOperation, and the import tracer had
hidden it by reporting only the shortest path. A package is only gone when
every edge to it is cut; verify with bench:modules, not with a path trace.

tests/setup/set.context.ts built a real client purely to give vi.mockObject a
shape. Once the import went dynamic that cost 337ms per call and pushed 19
tests past their 5s timeout. It is now left undefined, since every test that
exercises Kubernetes injects its own mock. The unit suite went 39s -> 21s.

678/678 unit tests and the integration-features suite pass. The docker
integration suite fails 16 tests both on this branch and on master (network
creation and an amd64/arm64 mismatch on this machine) — pre-existing, verified
by running the same spec on both.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
BaseCommand.init() built a Dockerode client on every command. dockerode brings
ssh2 (592KB) and @grpc/grpc-js (652KB) with it for transports emb never uses,
and commands like `emb ps` shell out to docker compose without touching the
Engine API at all.

EmbContext.docker is now optional and populated on first use via
getDockerClient(). dockerode is CommonJS, so createRequire keeps the accessor
synchronous — no await, and the 17 call sites changed shape only.

  emb ps    1125ms -> 1046ms
  emb tasks  922ms ->  883ms
  modules   1171 / 5.30MB -> 1004 / 3.53MB

Against the original baseline: emb ps 1683ms -> 1046ms (-38%), and 83% less
JavaScript parsed.

The plan called for a memoising getter on the context object, needing no
call-site changes. That is a trap worth recording: BaseCommand.init() does
setContext({ ...partialContext, monorepo }), and a spread invokes getters —
silently restoring the eager load. Any {...context} anywhere would do the same.
An explicit accessor cannot be defeated that way, and matches the Kubernetes
one.

Two type fixes fell out. retagIfNecessary/pushImage took
ReturnType<typeof getContext>['docker'], which became nullable — now typed
Docker directly. Five specs reading context.docker.x needed a non-null
assertion, matching existing test style.

678/678 unit tests and integration-features pass. `emb images` and
`emb containers` verified against the real daemon.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@llambeau
llambeau merged commit e688908 into master Aug 3, 2026
4 checks passed
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