diff --git a/src/helpers/quote.ts b/src/helpers/quote.ts index 9ec631f3..e5c01982 100644 --- a/src/helpers/quote.ts +++ b/src/helpers/quote.ts @@ -23,6 +23,15 @@ export function getPricePerGpuHourFromQuote( return quote.price / GPUS_PER_NODE / quote.quantity / durationHours; } +export function getPricePerNodeHourFromQuote( + quote: Pick, "start_at" | "end_at" | "price" | "quantity">, +) { + // A node is GPUS_PER_NODE GPUs, so the per-node-hour rate is simply the + // per-GPU-hour rate multiplied by GPUS_PER_NODE (equivalently: skip the + // division by GPUS_PER_NODE that getPricePerGpuHourFromQuote performs). + return getPricePerGpuHourFromQuote(quote) * GPUS_PER_NODE; +} + export async function getQuote(options: { instanceType?: string; quantity: number; diff --git a/src/helpers/test/quote.test.ts b/src/helpers/test/quote.test.ts index ab216f96..2cf91aeb 100644 --- a/src/helpers/test/quote.test.ts +++ b/src/helpers/test/quote.test.ts @@ -1,6 +1,5 @@ import { describe, expect, it } from "vitest"; -import { GPUS_PER_NODE } from "../../lib/constants.ts"; -import { getPricePerGpuHourFromQuote } from "../quote.ts"; +import { getPricePerNodeHourFromQuote } from "../quote.ts"; function makeQuote(opts: { priceCents: number; @@ -17,22 +16,20 @@ function makeQuote(opts: { }; } -function pricePerNodeHourFromQuote( - quote: ReturnType, -): number { - const pricePerGpuHour = getPricePerGpuHourFromQuote(quote); - return (pricePerGpuHour * GPUS_PER_NODE) / 100; +function pricePerNodeHourDollars(quote: ReturnType): number { + // getPricePerNodeHourFromQuote returns cents per node-hour. + return getPricePerNodeHourFromQuote(quote) / 100; } -describe("getPricePerGpuHourFromQuote", () => { - it("returns correct per-GPU-hour price for a single node", () => { +describe("getPricePerNodeHourFromQuote", () => { + it("returns correct per-node-hour price for a single node", () => { // 1 node, 4 hours, $12/node/hr = $48 total = 4800 cents const quote = makeQuote({ priceCents: 4800, quantity: 1, durationHours: 4, }); - const pricePerNodeHour = pricePerNodeHourFromQuote(quote); + const pricePerNodeHour = pricePerNodeHourDollars(quote); expect(pricePerNodeHour).toBeCloseTo(12.0); }); @@ -43,7 +40,7 @@ describe("getPricePerGpuHourFromQuote", () => { quantity: 8, durationHours: 4, }); - const pricePerNodeHour = pricePerNodeHourFromQuote(quote); + const pricePerNodeHour = pricePerNodeHourDollars(quote); expect(pricePerNodeHour).toBeCloseTo(12.0); }); }); @@ -60,8 +57,7 @@ describe("multi-node total price calculation (extend confirmation)", () => { ); const totalPricePerHour = quotes.reduce((acc, quote) => { - const pricePerGpuHour = getPricePerGpuHourFromQuote(quote); - const pricePerNodeHour = (pricePerGpuHour * GPUS_PER_NODE) / 100; + const pricePerNodeHour = getPricePerNodeHourFromQuote(quote) / 100; return acc + pricePerNodeHour; }, 0); const totalEstimate = totalPricePerHour * requestedDurationHours; @@ -81,8 +77,7 @@ describe("multi-node total price calculation (extend confirmation)", () => { ); const totalPricePerHour = quotes.reduce((acc, quote) => { - const pricePerGpuHour = getPricePerGpuHourFromQuote(quote); - const pricePerNodeHour = (pricePerGpuHour * GPUS_PER_NODE) / 100; + const pricePerNodeHour = getPricePerNodeHourFromQuote(quote) / 100; return acc + pricePerNodeHour; }, 0); const totalEstimate = totalPricePerHour * requestedDurationHours; diff --git a/src/lib/nodes/set.ts b/src/lib/nodes/set.ts index 988ab051..aaae6b28 100644 --- a/src/lib/nodes/set.ts +++ b/src/lib/nodes/set.ts @@ -103,7 +103,7 @@ async function setNodesAction( console.log( ` • ${result.name}: Max price set to $${result.maxPrice.toFixed( 2, - )}/GPU/hr`, + )}/node/hr`, ); } } diff --git a/src/lib/orders/OrderDisplay.tsx b/src/lib/orders/OrderDisplay.tsx index 4e03348b..854e7446 100644 --- a/src/lib/orders/OrderDisplay.tsx +++ b/src/lib/orders/OrderDisplay.tsx @@ -10,21 +10,21 @@ import type { HydratedOrder } from "./types.ts"; export function orderDetails(order: HydratedOrder) { const duration = dayjs(order.end_at).diff(order.start_at); const durationInHours = duration === 0 ? 1 : duration / 1000 / 60 / 60; - const pricePerGPUHour = - order.price / (order.quantity * durationInHours * GPUS_PER_NODE) / 100; + const pricePerNodeHour = + order.price / (order.quantity * durationInHours) / 100; const durationFormatted = formatDuration(duration); - const executedPriceDollarsPerGPUHour = + const executedPriceDollarsPerNodeHour = typeof order.execution_price === "number" ? order.execution_price / // cents - (order.quantity * GPUS_PER_NODE * durationInHours) / // cents per gpu-hour - 100 // dollars per gpu-hour + (order.quantity * durationInHours) / // cents per node-hour + 100 // dollars per node-hour : undefined; return { - pricePerGPUHour, + pricePerNodeHour, durationFormatted, - executedPriceDollarsPerGPUHour, + executedPriceDollarsPerNodeHour, }; } @@ -32,7 +32,7 @@ const formatDateTime = (date: string) => dayjs(date).format("MMM D h:mm a").toLowerCase(); function Order(props: { order: HydratedOrder }) { - const { pricePerGPUHour, durationFormatted } = orderDetails(props.order); + const { pricePerNodeHour, durationFormatted } = orderDetails(props.order); return ( @@ -55,7 +55,7 @@ function Order(props: { order: HydratedOrder }) { @@ -93,20 +96,20 @@ function OrderMinimal(props: { - {executedPriceDollarsPerGPUHour && - executedPriceDollarsPerGPUHour.toFixed(2) !== - pricePerGPUHour.toFixed(2) ? ( + {executedPriceDollarsPerNodeHour && + executedPriceDollarsPerNodeHour.toFixed(2) !== + pricePerNodeHour.toFixed(2) ? ( <> - ${pricePerGPUHour.toFixed(2)} - /gpu/hr + ${pricePerNodeHour.toFixed(2)} + /node/hr - ${executedPriceDollarsPerGPUHour.toFixed(2)} + ${executedPriceDollarsPerNodeHour.toFixed(2)} ) : ( - ${pricePerGPUHour.toFixed(2)} - /gpu/hr + ${pricePerNodeHour.toFixed(2)} + /node/hr )} diff --git a/src/lib/orders/__tests__/OrderDisplay.test.ts b/src/lib/orders/__tests__/OrderDisplay.test.ts index 377a76be..6b9ef7d6 100644 --- a/src/lib/orders/__tests__/OrderDisplay.test.ts +++ b/src/lib/orders/__tests__/OrderDisplay.test.ts @@ -1,5 +1,4 @@ import { expect, test } from "vitest"; -import { GPUS_PER_NODE } from "../../constants.ts"; import { orderDetails } from "../OrderDisplay.tsx"; import type { HydratedOrder } from "../types.ts"; import { OrderStatus } from "../types.ts"; @@ -25,11 +24,11 @@ const baseOrder: HydratedOrder = { status: OrderStatus.Open, }; -test("orderDetails - calculates price per GPU hour correctly", () => { +test("orderDetails - calculates price per node hour correctly", () => { const result = orderDetails(baseOrder); - // $100 / (1 quantity * 1 hour * GPUS_PER_NODE) - const expectedPricePerGPUHour = 100 / (1 * 1 * GPUS_PER_NODE); - expect(result.pricePerGPUHour).toEqual(expectedPricePerGPUHour); + // $100 / (1 quantity * 1 hour) + const expectedPricePerNodeHour = 100 / (1 * 1); + expect(result.pricePerNodeHour).toEqual(expectedPricePerNodeHour); }); test("orderDetails - handles zero duration by using 1 hour minimum", () => { @@ -39,24 +38,24 @@ test("orderDetails - handles zero duration by using 1 hour minimum", () => { end_at: "2024-02-20T00:00:00Z", }; const result = orderDetails(zeroOrder); - const expectedPricePerGPUHour = 100 / (1 * 1 * GPUS_PER_NODE); - expect(result.pricePerGPUHour).toEqual(expectedPricePerGPUHour); + const expectedPricePerNodeHour = 100 / (1 * 1); + expect(result.pricePerNodeHour).toEqual(expectedPricePerNodeHour); }); -test("orderDetails - calculates executed price per GPU hour when available", () => { +test("orderDetails - calculates executed price per node hour when available", () => { const executedOrder = { ...baseOrder, executed: true, execution_price: 8000, // 80 USD in cents }; const result = orderDetails(executedOrder); - const expectedExecutedPrice = 80 / (1 * 1 * GPUS_PER_NODE); - expect(result.executedPriceDollarsPerGPUHour).toEqual(expectedExecutedPrice); + const expectedExecutedPrice = 80 / (1 * 1); + expect(result.executedPriceDollarsPerNodeHour).toEqual(expectedExecutedPrice); }); -test("orderDetails - returns undefined for executedPriceDollarsPerGPUHour when no execution price", () => { +test("orderDetails - returns undefined for executedPriceDollarsPerNodeHour when no execution price", () => { const result = orderDetails(baseOrder); - expect(result.executedPriceDollarsPerGPUHour).toBeUndefined(); + expect(result.executedPriceDollarsPerNodeHour).toBeUndefined(); }); test("orderDetails - formats duration correctly", () => { @@ -71,7 +70,7 @@ test("orderDetails - handles multiple nodes and longer duration", () => { end_at: "2024-02-20T03:00:00Z", // 3 hours duration }; const result = orderDetails(multiNodeOrder); - // $100 / (2 quantity * 3 hours * GPUS_PER_NODE) - const expectedPricePerGPUHour = 100 / (2 * 3 * GPUS_PER_NODE); - expect(result.pricePerGPUHour).toEqual(expectedPricePerGPUHour); + // $100 / (2 quantity * 3 hours) + const expectedPricePerNodeHour = 100 / (2 * 3); + expect(result.pricePerNodeHour).toEqual(expectedPricePerNodeHour); }); diff --git a/src/lib/scale/ConfirmationMessage.tsx b/src/lib/scale/ConfirmationMessage.tsx index b0847c2e..1a18d6d6 100644 --- a/src/lib/scale/ConfirmationMessage.tsx +++ b/src/lib/scale/ConfirmationMessage.tsx @@ -1,6 +1,7 @@ import { Box, Text } from "ink"; import { formatDuration } from "../../helpers/format-time.ts"; import { InstanceTypeMetadata } from "../../helpers/instance-types-meta.ts"; +import { GPUS_PER_NODE } from "../constants.ts"; import { Row } from "../Row.tsx"; import { @@ -68,7 +69,11 @@ export default function ConfirmationMessage(props: { props.pricePerGpuHourInCents !== undefined ? ( - ${(props.pricePerGpuHourInCents / 100).toFixed(2)}/gpu/hr + $ + {((props.pricePerGpuHourInCents * GPUS_PER_NODE) / 100).toFixed( + 2, + )} + /node/hr {props.quote && (1.5x market)} diff --git a/src/lib/scale/ProcurementDisplay.tsx b/src/lib/scale/ProcurementDisplay.tsx index 17162d8d..4ea96a8f 100644 --- a/src/lib/scale/ProcurementDisplay.tsx +++ b/src/lib/scale/ProcurementDisplay.tsx @@ -48,7 +48,7 @@ export default function ProcurementDisplay({ }) { const horizonMinutes = horizon; const quantity = desired_quantity * GPUS_PER_NODE; - const pricePerGpuHourInCents = buy_limit_price_per_gpu_hour; + const pricePerNodeHourInCents = buy_limit_price_per_gpu_hour * GPUS_PER_NODE; const isSupportedType = instance_type in InstanceTypeMetadata; const typeLabel = isSupportedType ? InstanceTypeMetadata[instance_type].displayName @@ -75,7 +75,7 @@ export default function ProcurementDisplay({ { (async function init() { try { - let limitPricePerGpuHourInCents = props.price; + // `props.price` is the user-supplied limit price in cents per node-hour. + // The API expects cents per GPU-hour, so convert here. + let limitPricePerGpuHourInCents = + props.price === undefined ? undefined : props.price / GPUS_PER_NODE; // Get quote if price not specified and not skipping confirmation if (!props.yes && limitPricePerGpuHourInCents === undefined) { const quoteMinutes = Math.max(MIN_CONTRACT_MINUTES, props.horizon); @@ -272,7 +275,7 @@ function CreateProcurementCommand(props: CreateProcurementCommandProps) { } if (displayedPricePerGpuHourInCents === undefined) { - logAndQuit("Price per GPU hour could not be determined."); + logAndQuit("Price per node hour could not be determined."); } createProcurement({ @@ -378,8 +381,8 @@ Examples: \x1b[2m# Create a new procurement for 8 GPUs\x1b[0m $ sf scale create -n 8 -\x1b[2m# Maintain 32 GPUs, but only while the price is <= $1.50/GPU/hr\x1b[0m -$ sf scale create -n 32 -p 1.50 +\x1b[2m# Maintain 32 GPUs, but only while the price is <= $12.00/node/hr\x1b[0m +$ sf scale create -n 32 -p 12.00 \x1b[2m# Maintain 8 GPUs, start buying the next reservation when there's 30 minutes left\x1b[0m $ sf scale create -n 8 --horizon '30m' @@ -428,8 +431,8 @@ $ sf scale create -n 8 --horizon '30m' ) .option( "-p, --price ", - `Limit price per GPU per hour, in dollars. Buy compute only if it's at most this price. Defaults to the current market price times 1.5, or ${( - DEFAULT_PRICE_PER_GPU_HOUR_IN_CENTS / 100 + `Limit price per node per hour, in dollars. Buy compute only if it's at most this price. Defaults to the current market price times 1.5, or ${( + (DEFAULT_PRICE_PER_GPU_HOUR_IN_CENTS * GPUS_PER_NODE) / 100 ).toFixed(2)} if we can't get a price estimate.`, parsePriceArg, ) diff --git a/src/lib/scale/index.tsx b/src/lib/scale/index.tsx index b403c5f7..fd1c733c 100644 --- a/src/lib/scale/index.tsx +++ b/src/lib/scale/index.tsx @@ -43,8 +43,8 @@ $ sf scale update -n 16 \x1b[2m# Turn off procurements by scaling to 0\x1b[0m $ sf scale update -n 0 -\x1b[2m# Update the limit price of procurements to $1.50/GPU/hr\x1b[0m -$ sf scale update -p 1.50 +\x1b[2m# Update the limit price of procurements to $12.00/node/hr\x1b[0m +$ sf scale update -p 12.00 \x1b[2m# Start reserving more time 30 minutes before GPUs expire\x1b[0m $ sf scale update --horizon '30m' diff --git a/src/lib/scale/update.tsx b/src/lib/scale/update.tsx index 03515899..4cd53b25 100644 --- a/src/lib/scale/update.tsx +++ b/src/lib/scale/update.tsx @@ -14,6 +14,7 @@ import { import { apiClient } from "../../apiClient.ts"; import { logAndQuit } from "../../helpers/errors.ts"; import ConfirmInput from "../ConfirmInput.tsx"; +import { GPUS_PER_NODE } from "../constants.ts"; import ConfirmationMessage from "./ConfirmationMessage.tsx"; import ProcurementDisplay, { ProcurementHeader, @@ -150,6 +151,13 @@ function UpdateProcurementCommand(props: UpdateProcurementCommandProps) { const [displayedPricePerGpuHourInCents, setDisplayedPricePerGpuHourInCents] = useState(); + // `props.price` is the user-supplied limit price in cents per node-hour. + // The API and procurement records track cents per GPU-hour, so convert here. + const pricePerGpuHourInCents = useMemo( + () => (props.price === undefined ? undefined : props.price / GPUS_PER_NODE), + [props.price], + ); + // biome-ignore lint/correctness/useExhaustiveDependencies: This effect intentionally runs only on mount. We read props inside the effect but don't want to re-run when they change. useEffect(() => { (async function onInit() { @@ -186,7 +194,7 @@ function UpdateProcurementCommand(props: UpdateProcurementCommandProps) { } setProcurements(settledResults); - setDisplayedPricePerGpuHourInCents(props.price); + setDisplayedPricePerGpuHourInCents(pricePerGpuHourInCents); if (props.yes) { await updateProcurements( @@ -194,7 +202,7 @@ function UpdateProcurementCommand(props: UpdateProcurementCommandProps) { { horizonMinutes: props.horizon, nodesRequired, - pricePerGpuHourInCents: props.price, + pricePerGpuHourInCents, }, ); } else { @@ -216,9 +224,10 @@ function UpdateProcurementCommand(props: UpdateProcurementCommandProps) { props.horizon === p.horizon ? undefined : props.horizon } pricePerGpuHourInCents={ - props.price === p.buy_limit_price_per_gpu_hour + pricePerGpuHourInCents === + p.buy_limit_price_per_gpu_hour ? undefined - : props.price + : pricePerGpuHourInCents } accelerators={ props.accelerators !== undefined && @@ -381,8 +390,8 @@ $ sf scale update -n 16 \x1b[2m# Disable procurements (scale to 0 GPUs)\x1b[0m $ sf scale update -n 0 -\x1b[2m# Update the limit price of procurements to $1.50/GPU/hr\x1b[0m -$ sf scale update -p 1.50 +\x1b[2m# Update the limit price of procurements to $12.00/node/hr\x1b[0m +$ sf scale update -p 12.00 `, ) .configureHelp({ @@ -407,7 +416,7 @@ $ sf scale update -p 1.50 ) .option( "-p, --price ", - "Limit price per GPU per hour, in dollars. Buy compute only if it's at most this price. Defaults to the current market price times 1.5, or $2.65 if we can't get a price estimate.", + "Limit price per node per hour, in dollars. Buy compute only if it's at most this price. Defaults to the current market price times 1.5, or $21.20 if we can't get a price estimate.", parsePriceArg, ) .option("-y, --yes", "Automatically confirm the command.")