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
10 changes: 10 additions & 0 deletions .agents/skills/testing-workflow/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,16 @@ does not replace source validation. The release command commits matching root/TU
The release workflow rejects version mismatches and runs the full profile before
package installation checks.

## Performance coverage

Use [CONTRIBUTING.md](../../../CONTRIBUTING.md#performance-checks) to classify the
change. Add `perf:full` for performance optimizations, changes to history/token
processing, storage, streaming or tool execution, and large runtime refactors.
The label automatically selects the full suite and keeps it selected for later
commits. Before merge, link a passing `performance` run with `Suite: full` for the
latest head and intended base. Read [Performance CI](../../../docs/performance-ci.md)
for evidence, limits and manual dispatch.

## Manual evidence and reporting

For CLI behavior, exercise the built `dist/cli.js` and inspect stdout, stderr and
Expand Down
1 change: 1 addition & 0 deletions .github/PULL_REQUEST_TEMPLATE.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ Describe the user-visible problem and resulting behavior. Link a public issue wh
## Validation

- Checks run and results (include the revision/profile where relevant):
- Performance: basic / `perf:full` (see [requirements](https://github.com/MiniMax-AI/minimax-code/blob/main/CONTRIBUTING.md#performance-checks)); for full coverage, link a passing run for the latest PR head and intended base:
- NOT RUN, platform limitations and live-service boundaries:

## Publication and contribution checks
Expand Down
86 changes: 86 additions & 0 deletions .github/workflows/performance.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
name: Performance
on:
pull_request:
types: [opened, synchronize, reopened, labeled, unlabeled]
workflow_dispatch:
inputs:
base:
description: Baseline commit or branch
required: true
default: main
type: string
suite:
description: Performance suite (full includes startup and long history)
required: true
default: full
type: choice
options: [full, basic]
permissions:
contents: read
jobs:
performance:
# Unrelated label events must not replace the actual performance check.
name: ${{ contains(fromJSON('["labeled","unlabeled"]'), github.event.action) && github.event.label.name != 'perf:full' && 'performance (label ignored)' || 'performance' }}
if: ${{ !contains(fromJSON('["labeled","unlabeled"]'), github.event.action) || github.event.label.name == 'perf:full' }}
strategy:
matrix:
suite: ${{ fromJSON((inputs.suite == 'full' || (github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'perf:full'))) && '["full"]' || '["basic"]') }}
concurrency:
group: performance-${{ github.event_name }}-${{ github.ref }}-${{ github.event_name == 'workflow_dispatch' && matrix.suite || 'auto' }}
cancel-in-progress: true
runs-on: macos-15
timeout-minutes: ${{ matrix.suite == 'full' && 45 || 15 }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
path: candidate
ref: ${{ github.event.pull_request.head.sha || github.sha }}
persist-credentials: false
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
path: baseline
ref: ${{ github.event.pull_request.base.sha || inputs.base }}
persist-credentials: false
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
repository: KonghaYao/harness-perf-benchmark
ref: 8ad1e09f7653d7e97b83b4f5b4d347ca2fa129c7
path: benchmark
persist-credentials: false
- uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
with:
version: 9.12.0
package_json_file: candidate/package.json
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: 22.23.2
package-manager-cache: false
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
with:
bun-version: 1.4.2
- name: Build baseline
working-directory: baseline
run: |
pnpm install --frozen-lockfile
pnpm build
- name: Build candidate
working-directory: candidate
run: |
pnpm install --frozen-lockfile
pnpm build
- name: Install pinned benchmark
working-directory: benchmark
run: bun install --frozen-lockfile
- name: Compare on this runner
env:
PERF_SUITE: ${{ matrix.suite }}
run: node candidate/scripts/perf/run.mjs --base baseline --head candidate --benchmark benchmark --suite "$PERF_SUITE" --out "$RUNNER_TEMP/performance"
- name: Upload performance evidence
if: ${{ always() }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: performance-${{ matrix.suite }}-${{ github.run_attempt }}
path: ${{ runner.temp }}/performance/
include-hidden-files: true
retention-days: 14
if-no-files-found: warn
27 changes: 27 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,33 @@ Test files are declared in `test/vitest-suites.json`, grouped by the gate that r

Review added or removed files before running `node scripts/source-inventory.mjs --write`. Updating the inventory must not bypass checks for private protocols, internal addresses, credentials, or third-party licensing. Before a source release, also scan the complete Git history with Gitleaks; see the [release process](docs/releasing.md).

## Performance checks

Every PR runs the pinned 100-round upstream benchmark. Add the `perf:full` label
for the changes below; it automatically selects startup, 100-round and 300-round
long-history tests instead. Keep the label until merge so new commits run the
full suite too.

| Change | Required performance coverage |
| --- | --- |
| Performance optimization, including CPU, memory, startup or throughput fixes | `perf:full` |
| History construction/serialization, token estimation/caching, session storage, streaming or the tool execution loop | `perf:full` |
| Large runtime refactors spanning multiple components, or runtime dependency changes affecting these paths | `perf:full` |
| Other changes, including documentation-only edits and mechanical changes outside runtime paths | Automatic 100-round basic check |

The author adds the label; reviewers verify the classification. File count alone
does not make a change a large runtime refactor. Before merging a labeled PR,
require a successful `performance` check with `Suite: full` for the latest PR
head and intended base, and link the run in the PR's Validation section. Failed,
cancelled or inconclusive runs do not satisfy this requirement. If the base
changes after measurement, rerun against the updated base.

Removing `perf:full` restores the basic suite; do not remove it to bypass a
failure. Unrelated label changes do not run benchmarks. This is a review rule;
repository administrators must configure required checks separately. See
[Performance CI](docs/performance-ci.md) for manual dispatch, runner configuration
and raw evidence.

## Capability boundaries

Preserve MiniMax OAuth, Token Plan, BYOK, mcode-tools, search, plugins, connectors, updates, and feedback. Do not solve standalone-build problems by removing capabilities. Managed services use public clients; internal HTTP services, generated IDL, and cloud executor implementations are outside this repository.
Expand Down
82 changes: 82 additions & 0 deletions docs/performance-ci.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
# Performance CI

Every PR runs the `performance` check: the original 100-round scenario by default.
Adding `perf:full` automatically runs the full suite. While the label remains,
new commits and reopened PRs run the full suite too. Removing it starts a basic
run and cancels any previous automatic run for that PR. Unrelated label changes
skip benchmarking and do not replace the active `performance` check. Both
revisions build separately, then run serially on one `macos-15` runner with Node
22.23.2 and Bun 1.4.2. Build and installation time are excluded.

| Mode | Trigger | Scenarios | Job timeout |
| --- | --- | --- | --- |
| `basic` | PR without `perf:full`; optional manual run | `upstream-100` | 15 minutes |
| `full` | Add `perf:full`, then new commits/reopens while labeled; optional manual run | Startup, `upstream-100`, `history-300` | 45 minutes |

| Scenario | Input | Purpose |
| --- | --- | --- |
| `startup` | 1 tool round, minimal body | Startup and shutdown |
| `upstream-100` | 100 rounds × approximately 4 KiB, 64-character chunks, zero delay | Original [harness-perf-benchmark](https://github.com/KonghaYao/harness-perf-benchmark) workload |
| `history-300` | 300 rounds × approximately 8 KiB | History growth beyond the 1,048,576 UTF-16-unit token cache budget |

The upstream commit is pinned in `scripts/perf/config.json` and the workflow.
Its generator, mock, sampler and CPU accounting run without source edits.
An observing loopback proxy forwards the request body and response stream to
the mock, validating the complete assistant/tool history on every request.
It records request bytes and validation duration in `requests.jsonl`, without
headers. This adds observer latency to wall time; it is identical on both sides
and outside the sampled CLI process tree. Absolute timings differ from running
the upstream demo without the observer.
The adapter uses synthetic workspaces and the repository's offline network
fixture. No live-model key is needed. The long scenario advertises a
2,000,000-token mock context to prevent compaction from shortening the history;
this is a stress fixture, not production model capacity. The standard scenario
keeps upstream provider defaults. The suite does not measure live-model quality,
latency, compaction acceptance or other products' rankings.

| Rule | Initial setting |
| --- | --- |
| Repetitions | One excluded warmup per revision/scenario; three measured pairs, alternating base/head order |
| Duration regression | Median increases by more than both 25% and 1 second |
| CPU regression | Median increases by more than both 20% and 0.5 core-seconds |
| Sampled peak RSS regression | Median increases by more than both 20% and 32 MiB |
| Pair confirmation | Candidate is worse in all three matched pairs |
| Noise | Range exceeds 30% of median on either revision: `INCONCLUSIVE`, exit 2 |
| Correctness | Exit 0; N+1 requests with complete wire history; N matched bash commands/results; expected echo, pwd and listing output; complete stored bodies; expected final response |
| Invalid evidence | Missing samples, wrong sampler, bad metrics, incomplete runs or changed fixture: fail |

These are initial regression budgets, not service-level targets or statistical
confidence intervals. `REGRESSION` exits 1; `PASS` exits 0. An inconclusive run
needs another clean measurement; it is neither a confirmed regression nor a pass.
Tune budgets from repeated same-revision measurements. Review changes to
scenarios, comparison logic or limits as changes to the performance contract.
The workflow does not change branch protection; maintainers can add
`performance` as a required check after runner calibration; its name stays the
same for basic and full runs. The summary and artifact identify the selected
suite. Follow the [label and review requirements](../CONTRIBUTING.md#performance-checks)
when deciding which PRs need full coverage.

The Job Summary contains machine/method and comparison tables. The 14-day
artifact includes JSON results, configuration, commit and fixture hashes,
warmup/measured samples, CLI/mock logs, and synthetic histories. Confirmed
regressions get a separate candidate CPU-profile run, excluded from comparison.
Reports checkpoint after each run; setup failures before the runner starts
remain in job logs. CPU and sampled RSS are compared separately; the combined
CU score is not a gate. Sampling can miss brief memory peaks.

Install and build both checkouts with identical Node/pnpm versions and install
the pinned benchmark with `bun install --frozen-lockfile`, then run:

```bash
node scripts/perf/run.mjs --base /path/to/base --head /path/to/head \
--benchmark /path/to/pinned-benchmark --out /tmp/new-performance-output
```

The output directory must be new. The CLI defaults to `--suite basic`; pass
`--suite full` explicitly for the complete suite or `--scenario startup` for a
focused local check. Once the workflow is merged into the default branch, open
**Actions → Performance → Run workflow**, choose the PR branch, set the baseline
ref, and select `full` for long-history testing. Manual runs use a separate
concurrency group and do not cancel automatic PR checks. The workflow uses read-only
permissions and `pull_request`, without comments or secrets. See GitHub's
[event security guidance](https://docs.github.com/en/actions/reference/security/securely-using-pull_request_target).
6 changes: 6 additions & 0 deletions release/public-source.json

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

51 changes: 51 additions & 0 deletions scripts/perf/config.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
{
"benchmarkRepository": "https://github.com/KonghaYao/harness-perf-benchmark",
"benchmarkRevision": "8ad1e09f7653d7e97b83b4f5b4d347ca2fa129c7",
"warmups": 1,
"repetitions": 3,
"intervalMs": 100,
"maxSpread": 0.3,
"suites": {
"basic": ["upstream-100"],
"full": ["startup", "upstream-100", "history-300"]
},
"thresholds": {
"durationMs": {
"relative": 0.25,
"absolute": 1000
},
"cpuSeconds": {
"relative": 0.2,
"absolute": 0.5
},
"rssBytes": {
"relative": 0.2,
"absolute": 33554432
}
},
"scenarios": [
{
"id": "startup",
"turns": 1,
"bodyKb": 0,
"chunkSize": 64,
"timeoutMs": 60000
},
{
"id": "upstream-100",
"turns": 100,
"bodyKb": 4,
"chunkSize": 64,
"timeoutMs": 180000
},
{
"id": "history-300",
"turns": 300,
"bodyKb": 8,
"chunkSize": 64,
"timeoutMs": 360000,
"contextWindow": 2000000,
"minimumTextUnits": 1048577
}
]
}
81 changes: 81 additions & 0 deletions scripts/perf/driver.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
// Run under Bun. Reuse the pinned upstream generator, mock and sampler unchanged.
import { readFileSync, mkdirSync, writeFileSync, readdirSync, appendFileSync } from 'node:fs';
import { join, resolve } from 'node:path';
import { pathToFileURL } from 'node:url';
import { validateRun, validateRequest } from './report.mjs';

const input = JSON.parse(readFileSync(process.argv[2]!, 'utf8'));
const { runPerf } = await import(pathToFileURL(join(input.benchmark, 'scripts/perf/run.ts')).href);
const { loadPerfConfig } = await import(pathToFileURL(join(input.benchmark, 'scripts/perf/config.ts')).href);
const workspace = join(input.directory, 'workspace');
const data = join(input.directory, 'data');
mkdirSync(workspace, { recursive: true });
mkdirSync(data, { recursive: true });
writeFileSync(join(workspace, 'README.txt'), 'Synthetic performance fixture.\n');
const server = Bun.serve({ port: 0, hostname: '127.0.0.1', fetch: () => new Response('') });
const port = server.port;
server.stop(true);
const scenario = JSON.parse(readFileSync(input.script, 'utf8'));
let requests = 0;
let auditFailure: string | undefined;
const proxy = Bun.serve({ port: 0, hostname: '127.0.0.1', async fetch(request) {
const url = new URL(request.url);
const bytes = await request.arrayBuffer();
if (url.pathname === '/v1/chat/completions' && request.method === 'POST') {
requests++;
const start = performance.now();
let error: string | undefined;
try { validateRequest(JSON.parse(new TextDecoder().decode(bytes)), scenario.responses, requests, workspace, cfg.prompt); }
catch (failure) { error = String(failure); auditFailure ??= error; }
appendFileSync(join(input.directory, 'requests.jsonl'), JSON.stringify({ request: requests,
bytes: bytes.byteLength, validationMs: performance.now() - start, ok: !error, ...(error ? { error } : {}) }) + '\n');
}
return fetch(`http://127.0.0.1:${port}${url.pathname}${url.search}`, {
method: request.method, headers: { 'content-type': request.headers.get('content-type') ?? 'application/json' },
...(request.method === 'GET' || request.method === 'HEAD' ? {} : { body: bytes }),
});
} });
writeFileSync(join(data, 'config.yaml'), [
'logLevel: info', 'custom_provider:', ' llm-mock:', ' name: llm-mock',
' kind: custom', ' enabled: true', ' api: openai-completions',
' options:', ' apiKey: mock-key', ` baseURL: http://127.0.0.1:${proxy.port}/v1`,
' authMode: api-key', ' models:', ' llm-mock:', ' reasoning: true',
' thinking_config:', ' mode: switchable', " default_value: 'true'",
...(input.scenario.contextWindow ? [' limit:', ` context: ${input.scenario.contextWindow}`, ' output: 16384'] : []), '',
].join('\n'));
const cfg = loadPerfConfig([
'--script', input.script, '--exhausted', 'stop', '--work-dir', workspace,
'--out-dir', join(input.directory, 'raw'), '--harness', 'minimax-code',
'--port', String(port), '--interval-ms', String(input.intervalMs),
'--timeout-ms', String(input.scenario.timeoutMs), '--sampler', 'rusage',
'--prompt', '压测:请持续用只读命令检查当前目录状态',
]);
let code: number;
try { code = await runPerf(cfg, {
harnessCommand: () => [input.node,
...(input.profile ? ['--cpu-prof', `--cpu-prof-dir=${input.directory}`] : []),
'--import', pathToFileURL(join(input.control, 'test/network-deny.mjs')).href,
join(input.build, 'dist/cli.js'), 'exec', '--permission', 'off', '--cwd', workspace,
'--model', 'custom_provider:llm-mock/llm-mock', cfg.prompt],
harnessEnv: () => ({ MINIMAX_DATA_DIR: data, PI_TELEMETRY: '0',
MCODE_TEST_ALLOWED_ORIGIN: `http://127.0.0.1:${proxy.port}`,
MCODE_TEST_NETWORK_AUDIT: join(input.directory, 'network.log'), MCODE_TEST_MANAGED_OFFLINE: '1',
}),
}); } finally { proxy.stop(true); }
if (code !== 0) throw new Error(`Upstream runner failed: ${code}`);
if (auditFailure) throw new Error(`Request history validation failed: ${auditFailure}`);
if (requests !== input.scenario.turns + 1) throw new Error('Request audit count mismatch');
function files(directory: string): string[] {
return readdirSync(directory, { withFileTypes: true }).flatMap(entry => {
const name = join(directory, entry.name);
return entry.isDirectory() ? files(name) : [name];
});
}
const metas = files(join(input.directory, 'raw')).filter(p => p.endsWith('/run.json'));
if (metas.length !== 1) throw new Error('Expected exactly one upstream run');
const histories = files(data).filter(p => p.endsWith('/messages.jsonl'));
if (histories.length !== 1) throw new Error('Expected exactly one canonical session history');
const meta = JSON.parse(readFileSync(metas[0]!, 'utf8'));
const messages = readFileSync(histories[0]!, 'utf8').trim().split('\n').map(line => JSON.parse(line).message);
const metrics = validateRun(meta, messages, input.scenario, scenario.responses[input.scenario.turns].message.content, scenario.responses, workspace);
writeFileSync(join(input.directory, 'result.json'), JSON.stringify({ metrics, meta: resolve(metas[0]!), history: resolve(histories[0]!) }, null, 2));
Loading
Loading