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
76 changes: 73 additions & 3 deletions .github/workflows/ingest-agentic-results.yml
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,16 @@ on:
required: false
default: production
type: string
neon-branch:
description: Named Neon branch when database-target is neon-branch
required: false
default: ''
type: string
preview-url:
description: Preview deployment URL to invalidate for a named Neon branch
required: false
default: ''
type: string
secrets:
DATABASE_WRITE_URL:
description: Production database write connection for direct ingests
Expand All @@ -60,6 +70,12 @@ on:
INFX_MAIN_PAT:
description: Token used to download InferenceX artifacts
required: true
NEON_API_KEY:
description: Neon API key used to resolve named child branches
required: false
NEON_PROJECT_ID:
description: Neon project containing named child branches
required: false
SLACK_WEBHOOK_URL:
description: Optional failure and unmapped-entity notifications
required: false
Expand All @@ -83,6 +99,17 @@ on:
- production
- dev
- staging
- neon-branch
neon-branch:
description: Named Neon branch when database-target is neon-branch
required: false
default: ''
type: string
preview-url:
description: Preview deployment URL to invalidate for a named Neon branch
required: false
default: ''
type: string

permissions: {}

Expand Down Expand Up @@ -148,6 +175,10 @@ jobs:
PROTECTION_BYPASS_SECRET_STAGING: ${{ secrets.VERCEL_STAGING_BYPASS_SECRET }}
STAGING_SITE_URL: ${{ vars.STAGING_SITE_URL ||
'https://inferencemax-app-git-staging-semianalysisai.vercel.app' }}
NEON_API_KEY: ${{ secrets.NEON_API_KEY }}
NEON_PROJECT_ID: ${{ secrets.NEON_PROJECT_ID }}
NEON_BRANCH_NAME: ${{ inputs.neon-branch }}
PREVIEW_SITE_URL: ${{ inputs.preview-url }}
run: |
case "$REQUESTED_DATABASE_TARGET" in
production)
Expand All @@ -165,6 +196,40 @@ jobs:
cache_invalidate_url="${STAGING_SITE_URL%/}/api/v1/invalidate"
protection_bypass_secret="$PROTECTION_BYPASS_SECRET_STAGING"
;;
neon-branch)
if [ -z "$NEON_API_KEY" ] || [ -z "$NEON_PROJECT_ID" ] || [ -z "$NEON_BRANCH_NAME" ] || [ -z "$PREVIEW_SITE_URL" ]; then
echo "::error::NEON_API_KEY, NEON_PROJECT_ID, neon-branch, and preview-url are required"
exit 1
fi
branches=$(curl --retry 3 --retry-all-errors -sSf \
-H "Authorization: Bearer $NEON_API_KEY" \
"https://console.neon.tech/api/v2/projects/$NEON_PROJECT_ID/branches?limit=100")
branch_id=$(jq -r --arg name "$NEON_BRANCH_NAME" \
'.branches[] | select(.name == $name and .parent_id != null and .default != true) | .id' <<<"$branches" | head -n 1)
if [ -z "$branch_id" ] || [ "$branch_id" = "null" ]; then
echo "::error::Neon branch not found: $NEON_BRANCH_NAME"
exit 1
fi
database_write_url=$(npx --yes neonctl connection-string "$branch_id" \
--api-key "$NEON_API_KEY" \
--project-id "$NEON_PROJECT_ID" \
--database-name neondb \
--role-name neondb_owner)
endpoints=$(curl --retry 3 --retry-all-errors -sSf \
-H "Authorization: Bearer $NEON_API_KEY" \
"https://console.neon.tech/api/v2/projects/$NEON_PROJECT_ID/endpoints")
database_host=$(DATABASE_URL_TO_CHECK="$database_write_url" node -e \
'console.log(new URL(process.env.DATABASE_URL_TO_CHECK).hostname)')
if ! jq -e --arg branch "$branch_id" --arg host "$database_host" \
'.endpoints[] | select(.branch_id == $branch and .host == $host)' <<<"$endpoints" >/dev/null; then
echo "::error::Resolved database host does not belong to the requested child branch"
exit 1
fi
echo "Verified child database: $NEON_BRANCH_NAME ($branch_id), host $database_host"
cache_invalidate_url="${PREVIEW_SITE_URL%/}/api/v1/invalidate"
cache_invalidate_secret="$INVALIDATE_SECRET_DEFAULT"
protection_bypass_secret="$PROTECTION_BYPASS_SECRET_STAGING"
;;
*)
echo "::error::Unsupported database-target: $REQUESTED_DATABASE_TARGET"
exit 1
Expand All @@ -175,12 +240,12 @@ jobs:
echo "::error::Database secret is empty for target: $REQUESTED_DATABASE_TARGET"
exit 1
fi
if [ "$REQUESTED_DATABASE_TARGET" != "staging" ] && [ -z "$cache_invalidate_secret" ]; then
if [ "$REQUESTED_DATABASE_TARGET" != "staging" ] && [ "$REQUESTED_DATABASE_TARGET" != "neon-branch" ] && [ -z "$cache_invalidate_secret" ]; then
echo "::error::Cache invalidation secret is empty for target: $REQUESTED_DATABASE_TARGET"
exit 1
fi
if [ "$REQUESTED_DATABASE_TARGET" = "staging" ] && [ -z "$protection_bypass_secret" ]; then
echo "::error::Vercel protection bypass secret is empty for staging"
if { [ "$REQUESTED_DATABASE_TARGET" = "staging" ] || [ "$REQUESTED_DATABASE_TARGET" = "neon-branch" ]; } && [ -z "$protection_bypass_secret" ]; then
echo "::error::Vercel protection bypass secret is empty for preview target"
exit 1
fi

Expand Down Expand Up @@ -237,6 +302,11 @@ jobs:
if [ "$INGEST_DATABASE_TARGET" = "staging" ]; then
curl --retry 3 --retry-delay 2 --retry-connrefused -sSf -X POST "$CACHE_INVALIDATE_URL" \
-H "x-vercel-protection-bypass: $CACHE_PROTECTION_BYPASS_SECRET"
elif [ "$INGEST_DATABASE_TARGET" = "neon-branch" ]; then
curl --retry 3 --retry-delay 2 --retry-connrefused -sSf -X POST "$CACHE_INVALIDATE_URL" \
-H "x-vercel-protection-bypass: $CACHE_PROTECTION_BYPASS_SECRET" \
-H "Authorization: Bearer $CACHE_INVALIDATE_SECRET" || \
echo "::warning::Preview cache invalidation failed; the database ingest is complete"
else
curl -sSf -X POST "$CACHE_INVALIDATE_URL" \
-H "Authorization: Bearer $CACHE_INVALIDATE_SECRET" || true
Expand Down
48 changes: 48 additions & 0 deletions .github/workflows/recompute-agentic-metrics.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
name: Recompute Agentic Metrics
run-name: Recompute production metrics for run ${{ inputs.run-id }}

on:
workflow_dispatch:
inputs:
run-id:
description: Already-ingested GitHub benchmark run to recompute in production
required: true
type: string

permissions: {}

concurrency:
group: ${{ github.workflow }}-${{ inputs.run-id }}
cancel-in-progress: false

jobs:
recompute:
name: Recompute stored server metrics
if: github.ref == 'refs/heads/master'
runs-on: blacksmith-16vcpu-ubuntu-2404
timeout-minutes: 90
permissions:
contents: read
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version-file: package.json
- name: Install dependencies
run: bun install --frozen-lockfile
env:
CYPRESS_INSTALL_BINARY: '0'
- name: Recompute chart series and aggregate statistics
env:
DATABASE_WRITE_URL: ${{ secrets.DATABASE_WRITE_URL }}
RUN_ID: ${{ inputs.run-id }}
run: |
bun run --cwd packages/db db:backfill-chart-series --run-id "$RUN_ID" --force --yes
bun run --cwd packages/db db:backfill-aggregate-stats --run-id "$RUN_ID" --force --yes
- name: Invalidate production cache
env:
INVALIDATE_SECRET: ${{ secrets.VERCEL_INVALIDATE_SECRET }}
run: bun run admin:cache:invalidate https://inferencex.semianalysis.com
30 changes: 27 additions & 3 deletions .github/workflows/stage-results.yml
Original file line number Diff line number Diff line change
Expand Up @@ -254,7 +254,7 @@ jobs:
needs: [validate, prepare-staging-database]
permissions:
contents: read
uses: ./.github/workflows/ingest-agentic-results.yml
uses: $/.github/workflows/ingest-agentic-results.yml
Comment thread
cquil11 marked this conversation as resolved.
with:
run-id: ${{ needs.validate.outputs.run-id }}
run-attempt: ${{ needs.validate.outputs.run-attempt }}
Expand All @@ -265,10 +265,34 @@ jobs:
INFX_MAIN_PAT: ${{ secrets.INFX_MAIN_PAT }}
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}

refresh-chart-series:
name: Refresh staged chart series
needs: [validate, ingest]
runs-on: blacksmith-16vcpu-ubuntu-2404
permissions:
contents: read
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version-file: package.json
- name: Install dependencies
run: bun install --frozen-lockfile
env:
CYPRESS_INSTALL_BINARY: '0'
- name: Recompute stale chart series for staged run
env:
DATABASE_WRITE_URL: ${{ secrets.DATABASE_STAGING_WRITE_URL }}
RUN_ID: ${{ needs.validate.outputs.run-id }}
run: bun run --cwd packages/db db:backfill-chart-series --run-id "$RUN_ID" --yes

report:
name: Report staging result
if: ${{ always() && needs.validate.result == 'success' }}
needs: [validate, sync-staging-branch, prepare-staging-database, ingest]
needs: [validate, sync-staging-branch, prepare-staging-database, ingest, refresh-chart-series]
runs-on: ubuntu-latest
permissions: {}
steps:
Expand All @@ -280,7 +304,7 @@ jobs:
PR_NUMBER: ${{ needs.validate.outputs.pr-number }}
REQUESTED_BY: ${{ needs.validate.outputs.requested-by }}
COMMENT_ID: ${{ needs.validate.outputs.comment-id }}
STAGE_SUCCEEDED: ${{ needs['sync-staging-branch'].result == 'success' && needs['prepare-staging-database'].result == 'success' && needs.ingest.result == 'success' }}
STAGE_SUCCEEDED: ${{ needs['sync-staging-branch'].result == 'success' && needs['prepare-staging-database'].result == 'success' && needs.ingest.result == 'success' && needs['refresh-chart-series'].result == 'success' }}
with:
github-token: ${{ secrets.PAT }}
script: |
Expand Down
10 changes: 10 additions & 0 deletions docs/data-pipeline.md
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,16 @@ AIPerf defines the `server_metrics_export.json` envelope, but labels such as wor

Adapters are selected from the benchmark's canonical framework, and per-worker series are only emitted for disaggregated configs with a recognized adapter. Unknown orchestrators and non-disaggregated configs retain their aggregate-only series; roles are never guessed from ports or metric names. The frontend only consumes the canonical source identity and never interprets orchestrator-native labels.

Dynamo/TRT-LLM KV charts prefer `dynamo_component_gpu_cache_usage_percent`, which
retains `dp_rank`, over the rankless native `trtllm_kv_cache_utilization` gauge.
Ranks remain inside each worker's source; native-only endpoints retain their fallback.
The all-endpoints view summarizes workers, while selecting a worker exposes its ranks.

For runs ingested before a server-metric adapter was available, dispatch
`Recompute Agentic Metrics` on `master` with the benchmark run ID. It force-rebuilds
chart series and aggregate statistics from stored blobs for that run in production,
then invalidates the app cache. It does not rerun benchmarks or replace raw artifacts.

### Logical Engines vs Raw Series

A raw series in the blob is one `(scrape endpoint × phase block × label set)` tuple, which is **not** the same as one engine. The KV-cache chart needs one entry per _logical engine_ — one KV pool — so `compute-chart-series.ts` groups series by their Prometheus label set (`seriesIdentityKey`) rather than emitting one entry per raw series. Three kinds of duplication collapse there:
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import { describe, expect, it } from 'vitest';

import {
LEGEND_ITEM_GAP,
LEGEND_ROW_HEIGHT,
LEGEND_TEXT_OFFSET,
estimateTextWidth,
layoutChartLegend,
} from './chart-legend';

// Inline render of the point-detail charts: 720 viewBox units wide with the
// shared 60/16 left/right axis padding.
const INLINE_INNER_WIDTH = 720 - 60 - 16;

/** Right edge of the item at `index`, i.e. where its text stops. */
const itemRight = (labels: readonly string[], index: number, width: number): number => {
const { items } = layoutChartLegend(labels, width);
return items[index]!.x + LEGEND_TEXT_OFFSET + estimateTextWidth(labels[index]!);
};

describe('estimateTextWidth', () => {
it('grows with the number of characters', () => {
expect(estimateTextWidth('decode')).toBeGreaterThan(estimateTextWidth('dec'));
});

it('counts CJK characters as roughly one em each', () => {
// Four fullwidth characters at font size 11.
expect(estimateTextWidth('芯片缓存')).toBeCloseTo(44, 5);
});

it('charges more for wide characters than narrow ones', () => {
expect(estimateTextWidth('mmmm')).toBeGreaterThan(estimateTextWidth('llll'));
});

it('returns zero for an empty label', () => {
expect(estimateTextWidth('')).toBe(0);
});

it('scales linearly with font size', () => {
expect(estimateTextWidth('decode', 22)).toBeCloseTo(estimateTextWidth('decode', 11) * 2, 5);
});
});

describe('layoutChartLegend', () => {
it('keeps a short legend on one row and reserves no extra height', () => {
const layout = layoutChartLegend(['Input', 'Decode'], INLINE_INNER_WIDTH);
expect(layout.rows).toBe(1);
expect(layout.extraHeight).toBe(0);
expect(layout.items.map((i) => i.row)).toEqual([0, 0]);
});

it('lays same-row items out left to right without overlapping', () => {
const labels = ['Input', 'Decode'];
const { items } = layoutChartLegend(labels, INLINE_INNER_WIDTH);
expect(items[0]!.x).toBe(0);
expect(items[1]!.x).toBe(LEGEND_TEXT_OFFSET + estimateTextWidth(labels[0]!) + LEGEND_ITEM_GAP);
expect(items[1]!.x).toBeGreaterThanOrEqual(itemRight(labels, 0, INLINE_INNER_WIDTH));
});

it('wraps the eleven-series KV-cache legend instead of overprinting it', () => {
// The regression from the inline KV-cache chart: eleven per-engine labels
// in ~644 units used to get 58 units each and collide.
const labels = [
'prefill (500e)',
'prefill (501a)',
'prefill (501e)',
'decode (5021)',
'decode (5023)',
'decode (5025)',
'decode (5027)',
'Chip HBM (avg n=50)',
'DRAM',
'CPU offload pool (avg n=50)',
'Avg',
];
const layout = layoutChartLegend(labels, INLINE_INNER_WIDTH);

expect(layout.rows).toBeGreaterThan(1);
expect(layout.extraHeight).toBe((layout.rows - 1) * LEGEND_ROW_HEIGHT);

for (const [i, label] of labels.entries()) {
const item = layout.items[i]!;
const right = item.x + LEGEND_TEXT_OFFSET + estimateTextWidth(label);
// Every item fits inside the plot width...
expect(right).toBeLessThanOrEqual(INLINE_INNER_WIDTH);
// ...and starts clear of its predecessor on the same row.
const prev = layout.items[i - 1];
if (prev && prev.row === item.row) {
expect(item.x).toBeGreaterThanOrEqual(
prev.x + LEGEND_TEXT_OFFSET + estimateTextWidth(labels[i - 1]!),
);
}
}
});

it('starts each wrapped row back at the left edge', () => {
const labels = Array.from({ length: 12 }, (_, i) => `series number ${i}`);
const layout = layoutChartLegend(labels, INLINE_INNER_WIDTH);
const firstOfRow = new Map<number, number>();
for (const item of layout.items) {
if (!firstOfRow.has(item.row)) firstOfRow.set(item.row, item.x);
}
for (const x of firstOfRow.values()) expect(x).toBe(0);
});

it('wraps sooner for Chinese labels, which are wider per character', () => {
const en = Array.from({ length: 6 }, () => 'Chip HBM pool');
const zh = Array.from({ length: 6 }, () => '芯片 HBM 显存池均值');
expect(layoutChartLegend(zh, INLINE_INNER_WIDTH).rows).toBeGreaterThanOrEqual(
layoutChartLegend(en, INLINE_INNER_WIDTH).rows,
);
});

it('needs fewer rows at the expanded width than inline', () => {
const labels = Array.from({ length: 11 }, (_, i) => `decode engine ${i}`);
const inline = layoutChartLegend(labels, INLINE_INNER_WIDTH);
const expanded = layoutChartLegend(labels, 1300 - 60 - 16);
expect(expanded.rows).toBeLessThan(inline.rows);
});

it('gives a label wider than the row its own row rather than dropping it', () => {
const layout = layoutChartLegend(['a', 'w'.repeat(300), 'b'], 100);
expect(layout.items).toHaveLength(3);
expect(layout.items[1]!.row).toBe(1);
expect(layout.items[1]!.x).toBe(0);
expect(layout.items[2]!.row).toBe(2);
});

it('reports a single row for an empty legend', () => {
const layout = layoutChartLegend([], INLINE_INNER_WIDTH);
expect(layout.items).toEqual([]);
expect(layout.rows).toBe(1);
expect(layout.extraHeight).toBe(0);
});
});
Loading
Loading