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
66 changes: 66 additions & 0 deletions apps/api/src/gpu/http-schemas/gpu.schema.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { describe, expect, it, vi } from "vitest";

import { GpuBreakdownQuerySchema } from "./gpu.schema";

describe("GpuBreakdownQuerySchema", () => {
it("defaults startDate so the window covers the 30 days ending at endDate", () => {
const result = GpuBreakdownQuerySchema.parse({ endDate: "2024-01-31" });

expect(result.startDate).toBe("2024-01-02");
expect(result.endDate).toBe("2024-01-31");
});

it("computes startDate in UTC regardless of the process timezone", () => {
vi.stubEnv("TZ", "America/New_York");

try {
const result = GpuBreakdownQuerySchema.parse({ endDate: "2024-11-15" });

expect(result.startDate).toBe("2024-10-17");
} finally {
vi.unstubAllEnvs();
}
});

it("keeps the provided dates and filters untouched", () => {
const result = GpuBreakdownQuerySchema.parse({ startDate: "2024-01-01", endDate: "2024-01-31", vendor: "nvidia", model: "h100" });

expect(result).toEqual({ startDate: "2024-01-01", endDate: "2024-01-31", vendor: "nvidia", model: "h100" });
});

it("defaults endDate to today and covers 30 days when both dates are omitted", () => {
const result = GpuBreakdownQuerySchema.parse({});

expect(result.endDate).toBe(new Date().toISOString().split("T")[0]);
const windowDays = (Date.parse(result.endDate) - Date.parse(result.startDate)) / (24 * 60 * 60 * 1000) + 1;
expect(windowDays).toBe(30);
});

it("accepts a single-day range", () => {
const result = GpuBreakdownQuerySchema.parse({ startDate: "2024-01-31", endDate: "2024-01-31" });

expect(result.startDate).toBe("2024-01-31");
});

it("accepts a range of exactly 366 days", () => {
const result = GpuBreakdownQuerySchema.parse({ startDate: "2024-01-01", endDate: "2024-12-31" });

expect(result).toEqual({ startDate: "2024-01-01", endDate: "2024-12-31" });
});

it("rejects a range of 367 days", () => {
expect(() => GpuBreakdownQuerySchema.parse({ startDate: "2024-01-01", endDate: "2025-01-01" })).toThrow(
"Date range cannot exceed 366 days and startDate must not be after endDate"
);
});

it("rejects a startDate after the endDate", () => {
expect(() => GpuBreakdownQuerySchema.parse({ startDate: "2024-02-01", endDate: "2024-01-01" })).toThrow(
"Date range cannot exceed 366 days and startDate must not be after endDate"
);
});

it("rejects a date that is not YYYY-MM-DD", () => {
expect(() => GpuBreakdownQuerySchema.parse({ startDate: "01/01/2024" })).toThrow();
});
});
55 changes: 51 additions & 4 deletions apps/api/src/gpu/http-schemas/gpu.schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,10 +43,57 @@ export const ListGpuModelsResponseSchema = z.array(
})
);

export const GpuBreakdownQuerySchema = z.object({
vendor: z.string().optional(),
model: z.string().optional()
});
const DEFAULT_BREAKDOWN_WINDOW_DAYS = 30;
const MAX_BREAKDOWN_WINDOW_DAYS = 366;
const MS_PER_DAY = 24 * 60 * 60 * 1000;

function toIsoDate(date: Date) {
return date.toISOString().split("T")[0];
}

function startOfInclusiveWindow(endDate: string, windowDays: number) {
const date = new Date(`${endDate}T00:00:00.000Z`);
date.setUTCDate(date.getUTCDate() - (windowDays - 1));
return toIsoDate(date);
}

function countInclusiveDays(startDate: string, endDate: string) {
return (Date.parse(endDate) - Date.parse(startDate)) / MS_PER_DAY + 1;
}

export const GpuBreakdownQuerySchema = z
.object({
vendor: z.string().optional(),
model: z.string().optional(),
startDate: z
.string()
.date()
.optional()
.openapi({
description: `Start date (YYYY-MM-DD), inclusive. Defaults to a ${DEFAULT_BREAKDOWN_WINDOW_DAYS}-day window ending at endDate`,
example: "2024-01-01"
}),
endDate: z.string().date().optional().openapi({
description: "End date (YYYY-MM-DD), inclusive. Defaults to today (UTC)",
example: "2024-01-31"
})
})
.transform(data => {
const endDate = data.endDate ?? toIsoDate(new Date());
const startDate = data.startDate ?? startOfInclusiveWindow(endDate, DEFAULT_BREAKDOWN_WINDOW_DAYS);

return { ...data, startDate, endDate };
})
.refine(
data => {
const windowDays = countInclusiveDays(data.startDate, data.endDate);

return windowDays >= 1 && windowDays <= MAX_BREAKDOWN_WINDOW_DAYS;
},
{
message: `Date range cannot exceed ${MAX_BREAKDOWN_WINDOW_DAYS} days and startDate must not be after endDate`
}
);
export const GpuBreakdownResponseSchema = z.array(
z.object({
date: z.string(),
Expand Down
102 changes: 58 additions & 44 deletions apps/api/src/gpu/repositories/gpu.repository.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { sub } from "date-fns";
import { addDays, sub } from "date-fns";
import { QueryTypes, Sequelize } from "sequelize";
import { inject, injectable } from "tsyringe";

Expand Down Expand Up @@ -78,7 +78,10 @@ export class GpuRepository {
);
}

async getGpuBreakdown({ vendor, model }: GpuBreakdownQuery) {
async getGpuBreakdown({ vendor, model, startDate, endDate }: GpuBreakdownQuery) {
const windowStart = new Date(`${startDate}T00:00:00.000Z`);
const windowEndExclusive = addDays(new Date(`${endDate}T00:00:00.000Z`), 1);

const result = await this.#chainDb.query<{
date: Date;
vendor: string;
Expand All @@ -90,54 +93,65 @@ export class GpuRepository {
gpuUtilization: number;
}>(
`/* gpu:breakdown */
WITH UTILIZATION AS (
WITH daily_snapshots AS (
SELECT DISTINCT ON (p."hostUri", DATE(ps."checkDate"))
Comment thread
coderabbitai[bot] marked this conversation as resolved.
ps.id AS "snapshotId",
p."hostUri",
DATE(ps."checkDate") AS date
FROM "providerSnapshot" ps
INNER JOIN "provider" p ON p."owner" = ps."owner"
WHERE ps."isLastSuccessOfDay" = TRUE
AND ps."checkDate" >= :window_start
AND ps."checkDate" < :window_end_exclusive
ORDER BY p."hostUri", DATE(ps."checkDate"), ps."checkDate" DESC
),
gpu_nodes AS (
SELECT
d."date",
COALESCE(gpu."vendor", 'Unknown') as "vendor",
COALESCE(gpu."name", 'Unknown') as "model",
COALESCE(COUNT(DISTINCT "dailyProviderStats"."hostUri"), 0) as provider_count,
COALESCE(COUNT(DISTINCT n.id), 0) as node_count,
COALESCE(COUNT(gpu.id), 0) as total_gpus,
LEAST(COALESCE(CAST(ROUND(SUM(
CAST(n."gpuAllocated" as float) /
NULLIF((SELECT COUNT(*)
FROM "providerSnapshotNodeGPU" subgpu
WHERE subgpu."snapshotNodeId" = n.id), 0)
)) as int), 0), COUNT(gpu.id)) as leased_gpus,
LEAST(CAST(COALESCE(
SUM(
CAST(n."gpuAllocated" as float) /
NULLIF((SELECT COUNT(*)
FROM "providerSnapshotNodeGPU" subgpu
WHERE subgpu."snapshotNodeId" = n.id), 0)
) * 100.0 / NULLIF(COUNT(gpu.id), 0)
, 0) as numeric(10,2)), 100.00) as "gpuUtilization"
FROM "day" d
INNER JOIN (
SELECT DISTINCT ON("hostUri", DATE("checkDate"))
ps.id as "snapshotId",
"hostUri",
DATE("checkDate") AS date,
ps."isOnline"
FROM "providerSnapshot" ps
INNER JOIN "provider" ON "provider"."owner" = ps."owner"
WHERE ps."isLastSuccessOfDay" = TRUE
ORDER BY "hostUri", DATE("checkDate"), "checkDate" DESC
) "dailyProviderStats" ON DATE(d."date") = "dailyProviderStats"."date"
INNER JOIN "providerSnapshotNode" n ON n."snapshotId" = "dailyProviderStats"."snapshotId" AND n."gpuAllocatable" > 0
LEFT JOIN "providerSnapshotNodeGPU" gpu ON gpu."snapshotNodeId" = n.id
WHERE (:vendor IS NULL OR LOWER(gpu."vendor") = LOWER(:vendor))
n.id,
n."gpuAllocated",
s."hostUri",
s.date,
gpu_count.total AS gpu_count
FROM daily_snapshots s
INNER JOIN "providerSnapshotNode" n ON n."snapshotId" = s."snapshotId" AND n."gpuAllocatable" > 0
LEFT JOIN LATERAL (
SELECT COUNT(*) AS total
FROM "providerSnapshotNodeGPU" g
WHERE g."snapshotNodeId" = n.id
) gpu_count ON TRUE
)
SELECT
d."date",
COALESCE(gpu."vendor", 'Unknown') AS "vendor",
COALESCE(gpu."name", 'Unknown') AS "model",
COUNT(DISTINCT n."hostUri") AS provider_count,
COUNT(DISTINCT n.id) AS node_count,
COUNT(gpu.id) AS total_gpus,
LEAST(
ROUND(COALESCE(SUM(n."gpuAllocated"::float / NULLIF(n.gpu_count, 0)), 0))::int,
COUNT(gpu.id)
) AS leased_gpus,
LEAST(
ROUND(COALESCE(SUM(n."gpuAllocated"::float / NULLIF(n.gpu_count, 0)) * 100.0 / NULLIF(COUNT(gpu.id), 0), 0)::numeric, 2),
100
)::float AS "gpuUtilization"
FROM "day" d
INNER JOIN gpu_nodes n ON n.date = DATE(d."date")
LEFT JOIN "providerSnapshotNodeGPU" gpu ON gpu."snapshotNodeId" = n.id
WHERE d."date" >= :window_start
AND d."date" < :window_end_exclusive
AND (:vendor IS NULL OR LOWER(gpu."vendor") = LOWER(:vendor))
AND (:model IS NULL OR LOWER(gpu."name") = LOWER(:model))
GROUP BY d."date", gpu."vendor", gpu."name"
ORDER BY d."date" ASC, gpu."vendor", gpu."name"
)
SELECT * FROM UTILIZATION
`,
GROUP BY d."date", gpu."vendor", gpu."name"
ORDER BY d."date" ASC, gpu."vendor", gpu."name"
`,
{
type: QueryTypes.SELECT,
replacements: {
vendor: vendor ?? null,
model: model ?? null
model: model ?? null,
window_start: windowStart,
window_end_exclusive: windowEndExclusive
}
}
);
Expand Down
8 changes: 6 additions & 2 deletions apps/api/src/gpu/routes/gpu.router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,18 +134,22 @@ const gpuBreakdownRoute = createRoute({
tags: ["Gpu"],
security: SECURITY_NONE,
cache: { maxAge: 120, staleWhileRevalidate: 300 },
summary: "Gets gpu analytics breakdown by vendor and model. If no vendor or model is provided, all GPUs are returned.",
summary:
"Gets the daily gpu analytics breakdown by vendor and model over a date range (default: last 30 days, max 366 days). If no vendor or model is provided, all GPUs are returned.",
request: {
query: GpuBreakdownQuerySchema
},
responses: {
200: {
description: "Gets gpu analytics breakdown by vendor and model. If no vendor or model is provided, all GPUs are returned.",
description: "Daily gpu breakdown rows for the requested date range, one per date, vendor and model.",
Comment thread
baktun14 marked this conversation as resolved.
content: {
"application/json": {
schema: GpuBreakdownResponseSchema
}
}
},
400: {
description: "Invalid date range: dates must be YYYY-MM-DD, startDate must not be after endDate and the range cannot exceed 366 days"
}
}
});
Expand Down
26 changes: 19 additions & 7 deletions apps/api/src/gpu/services/gpu.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,12 +25,14 @@ describe(GpuService.name, () => {
});

describe("getGpuBreakdown", () => {
const window = { startDate: "2024-01-01", endDate: "2024-01-31" };

it("serves a repeated query from cache", async () => {
const { service, gpuRepository } = setup();
gpuRepository.getGpuBreakdown.mockResolvedValue([]);

await service.getGpuBreakdown({ vendor: "nvidia" });
await service.getGpuBreakdown({ vendor: "nvidia" });
await service.getGpuBreakdown({ ...window, vendor: "nvidia" });
await service.getGpuBreakdown({ ...window, vendor: "nvidia" });

expect(gpuRepository.getGpuBreakdown).toHaveBeenCalledTimes(1);
});
Expand All @@ -39,8 +41,8 @@ describe(GpuService.name, () => {
const { service, gpuRepository } = setup();
gpuRepository.getGpuBreakdown.mockResolvedValue([]);

await service.getGpuBreakdown({ vendor: "a", model: "b#c" });
await service.getGpuBreakdown({ vendor: "a#b", model: "c" });
await service.getGpuBreakdown({ ...window, vendor: "a", model: "b#c" });
await service.getGpuBreakdown({ ...window, vendor: "a#b", model: "c" });

expect(gpuRepository.getGpuBreakdown).toHaveBeenCalledTimes(2);
});
Expand All @@ -49,12 +51,22 @@ describe(GpuService.name, () => {
const { service, gpuRepository } = setup();
gpuRepository.getGpuBreakdown.mockResolvedValue([]);

await service.getGpuBreakdown({});
await service.getGpuBreakdown({ vendor: "nvidia" });
await service.getGpuBreakdown({ model: "nvidia" });
await service.getGpuBreakdown({ ...window });
await service.getGpuBreakdown({ ...window, vendor: "nvidia" });
await service.getGpuBreakdown({ ...window, model: "nvidia" });

expect(gpuRepository.getGpuBreakdown).toHaveBeenCalledTimes(3);
});

it("fetches fresh results when the date range differs", async () => {
const { service, gpuRepository } = setup();
gpuRepository.getGpuBreakdown.mockResolvedValue([]);

await service.getGpuBreakdown({ ...window, vendor: "nvidia" });
await service.getGpuBreakdown({ startDate: "2024-02-01", endDate: "2024-02-29", vendor: "nvidia" });

expect(gpuRepository.getGpuBreakdown).toHaveBeenCalledTimes(2);
});
});

function setup(input?: { catalog?: ProviderConfigGpusType }) {
Expand Down
2 changes: 1 addition & 1 deletion apps/api/src/gpu/services/gpu.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ export class GpuService {
readonly getGpuBreakdown = memoizeAsync((query: GpuBreakdownQuery) => this.gpuRepository.getGpuBreakdown(query), {
cacheItemLimit: 500,
ttl: minutesToSeconds(5) * 1000,
getCacheKey: query => JSON.stringify([query.vendor, query.model]),
getCacheKey: query => JSON.stringify([query.vendor, query.model, query.startDate, query.endDate]),
name: "GpuService#getGpuBreakdown"
});
}
29 changes: 27 additions & 2 deletions apps/api/swagger/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -15520,7 +15520,7 @@
"Gpu"
],
"security": [],
"summary": "Gets gpu analytics breakdown by vendor and model. If no vendor or model is provided, all GPUs are returned.",
"summary": "Gets the daily gpu analytics breakdown by vendor and model over a date range (default: last 30 days, max 366 days). If no vendor or model is provided, all GPUs are returned.",
"parameters": [
{
"schema": {
Expand All @@ -15537,11 +15537,33 @@
"required": false,
"name": "model",
"in": "query"
},
{
"schema": {
"type": "string",
"format": "date",
"description": "Start date (YYYY-MM-DD), inclusive. Defaults to a 30-day window ending at endDate",
"example": "2024-01-01"
},
"required": false,
"name": "startDate",
"in": "query"
},
{
"schema": {
"type": "string",
"format": "date",
"description": "End date (YYYY-MM-DD), inclusive. Defaults to today (UTC)",
"example": "2024-01-31"
},
"required": false,
"name": "endDate",
"in": "query"
}
],
"responses": {
"200": {
"description": "Gets gpu analytics breakdown by vendor and model. If no vendor or model is provided, all GPUs are returned.",
"description": "Daily gpu breakdown rows for the requested date range, one per date, vendor and model.",
"content": {
"application/json": {
"schema": {
Expand Down Expand Up @@ -15588,6 +15610,9 @@
}
}
}
},
"400": {
"description": "Invalid date range: dates must be YYYY-MM-DD, startDate must not be after endDate and the range cannot exceed 366 days"
}
}
}
Expand Down
Loading