Skip to content
Closed
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
9 changes: 9 additions & 0 deletions src/helpers/quote.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,15 @@ export function getPricePerGpuHourFromQuote(
return quote.price / GPUS_PER_NODE / quote.quantity / durationHours;
}

export function getPricePerNodeHourFromQuote(
quote: Pick<NonNullable<Quote>, "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;
Expand Down
25 changes: 10 additions & 15 deletions src/helpers/test/quote.test.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -17,22 +16,20 @@ function makeQuote(opts: {
};
}

function pricePerNodeHourFromQuote(
quote: ReturnType<typeof makeQuote>,
): number {
const pricePerGpuHour = getPricePerGpuHourFromQuote(quote);
return (pricePerGpuHour * GPUS_PER_NODE) / 100;
function pricePerNodeHourDollars(quote: ReturnType<typeof makeQuote>): 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);
});

Expand All @@ -43,7 +40,7 @@ describe("getPricePerGpuHourFromQuote", () => {
quantity: 8,
durationHours: 4,
});
const pricePerNodeHour = pricePerNodeHourFromQuote(quote);
const pricePerNodeHour = pricePerNodeHourDollars(quote);
expect(pricePerNodeHour).toBeCloseTo(12.0);
});
});
Expand All @@ -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;
Expand All @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion src/lib/nodes/set.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ async function setNodesAction(
console.log(
` • ${result.name}: Max price set to $${result.maxPrice.toFixed(
2,
)}/GPU/hr`,
)}/node/hr`,
);
}
}
Expand Down
41 changes: 22 additions & 19 deletions src/lib/orders/OrderDisplay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,29 +10,29 @@ 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,
};
}

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 (
<Box flexDirection="column" marginBottom={1}>
Expand All @@ -55,7 +55,7 @@ function Order(props: { order: HydratedOrder }) {
<Row
headWidth={7}
head="price"
value={`$${pricePerGPUHour.toFixed(2)}/gpu/hr`}
value={`$${pricePerNodeHour.toFixed(2)}/node/hr`}
/>
<Row headWidth={7} head="time" value={durationFormatted} />
<Row
Expand All @@ -81,8 +81,11 @@ function OrderMinimal(props: {
order: HydratedOrder;
activeTab: "all" | "sell" | "buy";
}) {
const { pricePerGPUHour, durationFormatted, executedPriceDollarsPerGPUHour } =
orderDetails(props.order);
const {
pricePerNodeHour,
durationFormatted,
executedPriceDollarsPerNodeHour,
} = orderDetails(props.order);

return (
<Box gap={1}>
Expand All @@ -93,20 +96,20 @@ function OrderMinimal(props: {
</Box>

<Box width={18}>
{executedPriceDollarsPerGPUHour &&
executedPriceDollarsPerGPUHour.toFixed(2) !==
pricePerGPUHour.toFixed(2) ? (
{executedPriceDollarsPerNodeHour &&
executedPriceDollarsPerNodeHour.toFixed(2) !==
pricePerNodeHour.toFixed(2) ? (
<>
<Text strikethrough dimColor>
${pricePerGPUHour.toFixed(2)}
<Text dimColor>/gpu/hr</Text>
${pricePerNodeHour.toFixed(2)}
<Text dimColor>/node/hr</Text>
</Text>
<Text>${executedPriceDollarsPerGPUHour.toFixed(2)}</Text>
<Text>${executedPriceDollarsPerNodeHour.toFixed(2)}</Text>
</>
) : (
<Text>
${pricePerGPUHour.toFixed(2)}
<Text dimColor>/gpu/hr</Text>
${pricePerNodeHour.toFixed(2)}
<Text dimColor>/node/hr</Text>
</Text>
)}
</Box>
Expand Down
29 changes: 14 additions & 15 deletions src/lib/orders/__tests__/OrderDisplay.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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", () => {
Expand All @@ -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", () => {
Expand All @@ -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);
});
7 changes: 6 additions & 1 deletion src/lib/scale/ConfirmationMessage.tsx
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -68,7 +69,11 @@ export default function ConfirmationMessage(props: {
props.pricePerGpuHourInCents !== undefined ? (
<Box gap={1} minWidth={30} justifyContent="space-between">
<Text>
${(props.pricePerGpuHourInCents / 100).toFixed(2)}/gpu/hr
$
{((props.pricePerGpuHourInCents * GPUS_PER_NODE) / 100).toFixed(
2,
)}
/node/hr
</Text>
{props.quote && <Text dimColor>(1.5x market)</Text>}
</Box>
Expand Down
4 changes: 2 additions & 2 deletions src/lib/scale/ProcurementDisplay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -75,7 +75,7 @@ export default function ProcurementDisplay({
<Row
headWidth={15}
head="Limit Price"
value={`$${(pricePerGpuHourInCents / 100).toFixed(2)}/gpu/hr`}
value={`$${(pricePerNodeHourInCents / 100).toFixed(2)}/node/hr`}
/>
<Row
headWidth={15}
Expand Down
19 changes: 11 additions & 8 deletions src/lib/scale/create.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,8 @@ function ScaleWarning(props: CreateProcurementCommandProps) {
equivalentCommand += ` -z ${clusterName}`;
}
if (props.price) {
// Convert from cents per GPU/hr to dollars per node/hr
const pricePerNodeHour = (props.price * GPUS_PER_NODE) / 100;
// `props.price` is already cents per node/hr; convert to dollars per node/hr.
const pricePerNodeHour = props.price / 100;
equivalentCommand += ` -p ${pricePerNodeHour.toFixed(2)}`;
}
if (props.yes) {
Expand Down Expand Up @@ -167,7 +167,10 @@ function CreateProcurementCommand(props: CreateProcurementCommandProps) {
useEffect(() => {
(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);
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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'
Expand Down Expand Up @@ -428,8 +431,8 @@ $ sf scale create -n 8 --horizon '30m'
)
.option(
"-p, --price <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,
)
Expand Down
4 changes: 2 additions & 2 deletions src/lib/scale/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,8 @@ $ sf scale update <procurement-id...> -n 16
\x1b[2m# Turn off procurements by scaling to 0\x1b[0m
$ sf scale update <procurement-id...> -n 0

\x1b[2m# Update the limit price of procurements to $1.50/GPU/hr\x1b[0m
$ sf scale update <procurement-id...> -p 1.50
\x1b[2m# Update the limit price of procurements to $12.00/node/hr\x1b[0m
$ sf scale update <procurement-id...> -p 12.00

\x1b[2m# Start reserving more time 30 minutes before GPUs expire\x1b[0m
$ sf scale update <procurement-id...> --horizon '30m'
Expand Down
Loading
Loading