Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ jobs:
- run: npm ci
- run: npm run typecheck
- run: npm run format:check
- run: npm run build:cli
- run: node dist/cli/estimate.mjs chips --model k3 > /dev/null
- run: npm run bench
- run: npm test -- --exclude tests/bench.test.ts
env:
Expand Down
24 changes: 24 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,10 +51,34 @@ The explorer ranks sharding configurations per chip, and drills into any one wit
fabric view (the chips on their real interconnect) and an execution trace (the op
graph, colored by what each op is bound on).

## CLI

The same engine as a command line, `estimate`, for scripting sweeps and pinning
configurations in other repos. Build it once (`npm run build:cli`, a single
node script at `dist/cli/estimate.mjs`, no runtime dependencies) and:

```
estimate models # the presets
estimate chips --model k3 # chips, with the least chips that hold the weights
estimate search k3 gb300-nvl72 32x1 --slo 20 # rank every sharding, streaming as it prices
estimate top k3 b300 8x1 --phase prefill # the same, silent
estimate explain k3 b300 8x1 --sizes TP=8,EP=8 # one sharding, fully worked, with engine flags
estimate sweep --model k3 --chips b300,gb300-nvl72 --machines 8x1,32x1 --slos none,20 --table
```

`--json` emits one row per line; `sweep` writes one file per cell and `report`
re-renders them. Two knobs correct the simulator where its defaults mislead,
both off unless asked for: `--state-slots N` (with `--spec-slots`,
`--state-dtype`, `--mem-fraction`) reserves the recurrent-state slots a hybrid
KV manager actually holds per sequence on linear-attention layers, and
`--scheduler dag` reads overlap off the op graph instead of the two `--overlap`
fractions. Every number is an estimate.

## Layout

```
src/core engine (lowering, placement search, cost backends), model & hardware specs
src/ui the explorer — leaderboard, fabric view, execution trace
src/cli the estimate command line (bundled by scripts/build-cli.mjs)
tests property / fuzz tests
```
25 changes: 25 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

13 changes: 11 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,12 @@
"build": "tsc --noEmit && vite build",
"test": "vitest run",
"bench": "vitest run tests/bench.test.ts",
"search": "vite-node scripts/search.ts --",
"typecheck": "tsc --noEmit",
"preview": "vite preview",
"format": "prettier --write .",
"format:check": "prettier --check ."
"format:check": "prettier --check .",
"build:cli": "node scripts/build-cli.mjs",
"cli": "node scripts/build-cli.mjs && node dist/cli/estimate.mjs"
},
"prettier": {
"printWidth": 100,
Expand All @@ -32,12 +33,20 @@
"recharts": "^2.12.7"
},
"devDependencies": {
"@types/node": "^22.20.2",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.1",
"esbuild": "^0.21.5",
"prettier": "^3.6.2",
"typescript": "^5.5.3",
"vite": "^5.3.4",
"vitest": "^2.0.4"
},
"bin": {
"estimate": "dist/cli/estimate.mjs"
},
"engines": {
"node": ">=20"
}
}
29 changes: 29 additions & 0 deletions scripts/build-cli.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
// Bundle the CLI into one node script: the sources use extensionless
// imports under moduleResolution "bundler", which node cannot run as-is.
import { build } from 'esbuild';
import { execSync } from 'node:child_process';
import { mkdirSync } from 'node:fs';

const version = (() => {
try {
return execSync('git describe --tags --always --dirty', { stdio: ['ignore', 'pipe', 'ignore'] })
.toString()
.trim();
} catch {
return 'unknown';
}
})();

mkdirSync('dist/cli', { recursive: true });
await build({
entryPoints: ['src/cli/main.ts'],
outfile: 'dist/cli/estimate.mjs',
bundle: true,
platform: 'node',
format: 'esm',
target: 'node20',
banner: { js: '#!/usr/bin/env node' },
define: { __CLI_VERSION__: JSON.stringify(version) },
logLevel: 'warning',
});
console.error(`built dist/cli/estimate.mjs (${version})`);
73 changes: 0 additions & 73 deletions scripts/search.ts

This file was deleted.

134 changes: 134 additions & 0 deletions src/cli/args.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
import { parseArgs, type ParseArgsConfig } from 'node:util';
import type { Scheduler } from '../core/engine/sim/cost/select';
import { DEFAULT_SHARED, SharedOptions } from './context';
import { CliError } from './resolve';

type OptionTable = NonNullable<ParseArgsConfig['options']>;
export type Values = Record<string, string | boolean | undefined>;

// the knobs every evaluating subcommand takes
export const SHARED_OPTIONS = {
'prefill-len': { type: 'string' },
'gen-len': { type: 'string' },
overlap: { type: 'string' },
scheduler: { type: 'string' },
'cost-per-hour': { type: 'string' },
'mem-fraction': { type: 'string' },
'state-slots': { type: 'string' },
'state-dtype': { type: 'string' },
'spec-slots': { type: 'string' },
json: { type: 'boolean' },
help: { type: 'boolean', short: 'h' },
} satisfies OptionTable;

export const SHARED_HELP = `
shared options
--prefill-len N prompt tokens per sequence (default 4096)
--gen-len N generated tokens per sequence (default 1024)
--overlap M,C naive scheduler: fraction of memory and comms traffic
hidden behind the widest stream (default 0.9,0.65)
--scheduler naive|dag dag reads overlap off the op graph and ignores --overlap
--cost-per-hour USD override the chip's rental price ($/chip-hour)
--mem-fraction F fraction of HBM the stack lets weights+cache use
(SGLang mem_fraction_static); applied as a smaller chip
--state-slots N recurrent-state slots reserved per sequence on
linear-attention layers (default 1; SGLang radix cache 5)
--state-dtype T capacity dtype of that state: fp32 (default) or bf16
--spec-slots N extra state slots per sequence for speculative decoding
--json machine-readable output (one JSON object per line)
`.trimEnd();

export function parse<T extends OptionTable>(
argv: string[],
options: T,
allowPositionals = true,
): { values: Values; positionals: string[] } {
try {
const { values, positionals } = parseArgs({
args: argv,
options,
allowPositionals,
strict: true,
});
return { values: values as Values, positionals };
} catch (err) {
throw new CliError(err instanceof Error ? err.message : String(err), 2);
}
}

export function num(values: Values, name: string): number | undefined {
const v = values[name];
if (v === undefined || typeof v === 'boolean') return undefined;
const n = Number(v);
if (!Number.isFinite(n)) throw new CliError(`--${name} expects a number, got "${v}"`, 2);
return n;
}

export function str(values: Values, name: string): string | undefined {
const v = values[name];
return typeof v === 'string' ? v : undefined;
}

export function flag(values: Values, name: string): boolean {
return values[name] === true;
}

export function oneOf<const T extends readonly string[]>(
values: Values,
name: string,
allowed: T,
dflt: T[number],
): T[number] {
const v = str(values, name) ?? dflt;
if (!allowed.includes(v))
throw new CliError(`--${name} must be one of ${allowed.join('|')}, got "${v}"`, 2);
return v as T[number];
}

export function list(values: Values, name: string, dflt: string[] = []): string[] {
const v = str(values, name);
return v === undefined
? dflt
: v
.split(',')
.map((s) => s.trim())
.filter(Boolean);
}

export function sharedOptions(values: Values): SharedOptions {
const overlapArg = str(values, 'overlap');
const [memoryOverlap, commsOverlap] = overlapArg
? overlapArg.split(',').map(Number)
: [DEFAULT_SHARED.overlap.memoryOverlap, DEFAULT_SHARED.overlap.commsOverlap];
if (
overlapArg &&
![memoryOverlap, commsOverlap].every((x) => Number.isFinite(x) && x >= 0 && x <= 1)
)
throw new CliError(`--overlap expects two fractions "M,C", got "${overlapArg}"`, 2);
const scheduler: Scheduler = oneOf(values, 'scheduler', ['naive', 'dag'] as const, 'naive');

const statePool: SharedOptions['statePool'] = {};
const slots = num(values, 'state-slots');
if (slots !== undefined) statePool.slotsPerSeq = slots;
const spec = num(values, 'spec-slots');
if (spec !== undefined) statePool.specSlots = spec;
const dtype = str(values, 'state-dtype');
if (dtype !== undefined) {
const bytes = { fp32: 4, bf16: 2, fp16: 2, fp8: 1 }[dtype];
if (!bytes) throw new CliError(`--state-dtype must be fp32|bf16|fp16|fp8, got "${dtype}"`, 2);
statePool.stateDtypeBytes = bytes;
}

const memFraction = num(values, 'mem-fraction');
if (memFraction !== undefined && !(memFraction > 0 && memFraction <= 1))
throw new CliError(`--mem-fraction must be in (0, 1], got ${memFraction}`, 2);

return {
prefillLen: num(values, 'prefill-len') ?? DEFAULT_SHARED.prefillLen,
genLen: num(values, 'gen-len') ?? DEFAULT_SHARED.genLen,
overlap: { memoryOverlap, commsOverlap, scheduler },
costPerHour: num(values, 'cost-per-hour'),
memFraction,
statePool,
};
}
Loading
Loading