feat(gpu-service): support sliced (by-ratio) GPU instances and PV status
- Add whole/by-ratio segmented toggle to the GPU Instance form; sliced mode picks a VRAM ratio (10-100 or finer 1-10 when max<10), scales CPU/RAM by the ratio (floored, min 1), pins compute at 100% - Availability, card Max, and Instance Type cell reflect sliceable types - Add ready/deleting status columns for storage and storage types
This commit is contained in:
@@ -97,6 +97,38 @@ const handleBChange = (b) => {
|
|||||||
};
|
};
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## 4. Controlled input with derived fields
|
||||||
|
|
||||||
|
When a controlled field's value comes from **both** user input and a programmatic default (e.g. a percentage picked on a slider, and a default seeded on select / mode-switch), funnel both through **one commit function** — don't duplicate "write field + recompute derived" per call site.
|
||||||
|
|
||||||
|
- The `Form.Item`-bound input's `onChange(value)` forwards the value to the commit fn (the field is antd-bound, but pass the value explicitly so the default path can reuse the same fn instead of reading the store).
|
||||||
|
- Seed defaults by calling the **same** commit fn with the computed value.
|
||||||
|
- Separate the **commit action** (write field + recompute dependents) from the **render-derive** (read the field → recompute dependents). Keeping the derive standalone lets it re-run on reload/edit where there's no user event.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// commit action — slider onChange AND default both call this
|
||||||
|
const commitRatio = (value: number) => {
|
||||||
|
form.setFieldsValue({ spec: { resources: { ratio: value, cores: 100 } } });
|
||||||
|
rescaleDerived(); // reads ratio from the form, sets the disabled cpu/ram
|
||||||
|
};
|
||||||
|
|
||||||
|
// render-derive — also called from the edit/reload effect
|
||||||
|
const rescaleDerived = () => {
|
||||||
|
const ratio = form.getFieldValue(['spec', 'resources', 'ratio']);
|
||||||
|
form.setFieldsValue({
|
||||||
|
spec: {
|
||||||
|
resources: {
|
||||||
|
cpu: floorScale(unit.cpu, ratio),
|
||||||
|
ram: floorScale(unit.ram, ratio)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// default seeding reuses the commit fn — one path, not a second copy
|
||||||
|
const applyDefaults = (item) => commitRatio(Math.min(10, item.maxRatio) || 10);
|
||||||
|
```
|
||||||
|
|
||||||
## Related
|
## Related
|
||||||
|
|
||||||
- Module/file structure for forms lives in the **create-crud-page** skill (section 3).
|
- Module/file structure for forms lives in the **create-crud-page** skill (section 3).
|
||||||
|
|||||||
@@ -119,8 +119,17 @@ export default {
|
|||||||
'No available GPU resources, please choose another instance type.',
|
'No available GPU resources, please choose another instance type.',
|
||||||
'gpuservice.instance.gpuCount.zero':
|
'gpuservice.instance.gpuCount.zero':
|
||||||
'CPU-only setup for environment preparation.',
|
'CPU-only setup for environment preparation.',
|
||||||
|
'gpuservice.instance.mode.whole': 'Full GPU',
|
||||||
|
'gpuservice.instance.mode.sliced': 'By Ratio',
|
||||||
|
'gpuservice.instance.slice.memoryPercentage': 'VRAM Percentage (%)',
|
||||||
|
'gpuservice.instance.slice.fullCores': '100% Compute',
|
||||||
|
'gpuservice.instance.slice.percentage.required':
|
||||||
|
'Please select or enter a percentage',
|
||||||
|
'gpuservice.instance.slice.percentage.max':
|
||||||
|
'The ratio must be between 1% and {count}%',
|
||||||
'gpuservice.instance.stock': 'Stock',
|
'gpuservice.instance.stock': 'Stock',
|
||||||
'gpuservice.instance.sliced': 'Sliced',
|
'gpuservice.instance.sliced': 'Sliced',
|
||||||
|
'gpuservice.instance.sliceable': 'Sliceable',
|
||||||
'gpuservice.instance.memory': 'VRAM',
|
'gpuservice.instance.memory': 'VRAM',
|
||||||
'gpuservice.instance.ram': 'RAM',
|
'gpuservice.instance.ram': 'RAM',
|
||||||
'gpuservice.instance.os': 'OS',
|
'gpuservice.instance.os': 'OS',
|
||||||
|
|||||||
@@ -118,8 +118,17 @@ export default {
|
|||||||
'gpuservice.instance.gpuCount.noAvailable':
|
'gpuservice.instance.gpuCount.noAvailable':
|
||||||
'利用可能な GPU リソースがありません。別のインスタンスタイプを選択してください。',
|
'利用可能な GPU リソースがありません。別のインスタンスタイプを選択してください。',
|
||||||
'gpuservice.instance.gpuCount.zero': 'CPU のみを使用し、環境準備用です。',
|
'gpuservice.instance.gpuCount.zero': 'CPU のみを使用し、環境準備用です。',
|
||||||
|
'gpuservice.instance.mode.whole': 'GPU 全体',
|
||||||
|
'gpuservice.instance.mode.sliced': '比率で',
|
||||||
|
'gpuservice.instance.slice.memoryPercentage': 'VRAM の割合(%)',
|
||||||
|
'gpuservice.instance.slice.fullCores': '100% コンピュート',
|
||||||
|
'gpuservice.instance.slice.percentage.required':
|
||||||
|
'パーセンテージを選択または入力してください',
|
||||||
|
'gpuservice.instance.slice.percentage.max':
|
||||||
|
'比率は 1% から {count}% の間で指定してください',
|
||||||
'gpuservice.instance.stock': '在庫',
|
'gpuservice.instance.stock': '在庫',
|
||||||
'gpuservice.instance.sliced': '分割',
|
'gpuservice.instance.sliced': '分割',
|
||||||
|
'gpuservice.instance.sliceable': '分割可能',
|
||||||
'gpuservice.instance.memory': 'VRAM',
|
'gpuservice.instance.memory': 'VRAM',
|
||||||
'gpuservice.instance.ram': 'RAM',
|
'gpuservice.instance.ram': 'RAM',
|
||||||
'gpuservice.instance.os': 'OS',
|
'gpuservice.instance.os': 'OS',
|
||||||
|
|||||||
@@ -117,8 +117,17 @@ export default {
|
|||||||
'gpuservice.instance.gpuCount.noAvailable':
|
'gpuservice.instance.gpuCount.noAvailable':
|
||||||
'Нет доступных ресурсов GPU, выберите другой тип экземпляра.',
|
'Нет доступных ресурсов GPU, выберите другой тип экземпляра.',
|
||||||
'gpuservice.instance.gpuCount.zero': 'Только CPU, для подготовки окружения.',
|
'gpuservice.instance.gpuCount.zero': 'Только CPU, для подготовки окружения.',
|
||||||
|
'gpuservice.instance.mode.whole': 'Весь GPU',
|
||||||
|
'gpuservice.instance.mode.sliced': 'По доле',
|
||||||
|
'gpuservice.instance.slice.memoryPercentage': 'Доля VRAM (%)',
|
||||||
|
'gpuservice.instance.slice.fullCores': '100% вычислений',
|
||||||
|
'gpuservice.instance.slice.percentage.required':
|
||||||
|
'Выберите или введите процент',
|
||||||
|
'gpuservice.instance.slice.percentage.max':
|
||||||
|
'Доля должна быть от 1% до {count}%',
|
||||||
'gpuservice.instance.stock': 'Остаток',
|
'gpuservice.instance.stock': 'Остаток',
|
||||||
'gpuservice.instance.sliced': 'Разделено',
|
'gpuservice.instance.sliced': 'Разделено',
|
||||||
|
'gpuservice.instance.sliceable': 'Делимый',
|
||||||
'gpuservice.instance.memory': 'VRAM',
|
'gpuservice.instance.memory': 'VRAM',
|
||||||
'gpuservice.instance.ram': 'RAM',
|
'gpuservice.instance.ram': 'RAM',
|
||||||
'gpuservice.instance.os': 'ОС',
|
'gpuservice.instance.os': 'ОС',
|
||||||
|
|||||||
@@ -113,8 +113,17 @@ export default {
|
|||||||
'gpuservice.instance.gpuCount.noAvailable':
|
'gpuservice.instance.gpuCount.noAvailable':
|
||||||
'Kullanılabilir GPU kaynağı yok, lütfen başka bir örnek türü seçin.',
|
'Kullanılabilir GPU kaynağı yok, lütfen başka bir örnek türü seçin.',
|
||||||
'gpuservice.instance.gpuCount.zero': 'Yalnızca CPU, ortam hazırlığı için.',
|
'gpuservice.instance.gpuCount.zero': 'Yalnızca CPU, ortam hazırlığı için.',
|
||||||
|
'gpuservice.instance.mode.whole': 'Tam GPU',
|
||||||
|
'gpuservice.instance.mode.sliced': 'Orana Göre',
|
||||||
|
'gpuservice.instance.slice.memoryPercentage': 'VRAM Yüzdesi (%)',
|
||||||
|
'gpuservice.instance.slice.fullCores': '%100 İşlem Gücü',
|
||||||
|
'gpuservice.instance.slice.percentage.required':
|
||||||
|
'Lütfen bir yüzde seçin veya girin',
|
||||||
|
'gpuservice.instance.slice.percentage.max':
|
||||||
|
'Oran %1 ile %{count} arasında olmalıdır',
|
||||||
'gpuservice.instance.stock': 'Stok',
|
'gpuservice.instance.stock': 'Stok',
|
||||||
'gpuservice.instance.sliced': 'Bölünmüş',
|
'gpuservice.instance.sliced': 'Bölünmüş',
|
||||||
|
'gpuservice.instance.sliceable': 'Bölünebilir',
|
||||||
'gpuservice.instance.memory': 'VRAM',
|
'gpuservice.instance.memory': 'VRAM',
|
||||||
'gpuservice.instance.ram': 'RAM',
|
'gpuservice.instance.ram': 'RAM',
|
||||||
'gpuservice.instance.os': 'OS',
|
'gpuservice.instance.os': 'OS',
|
||||||
|
|||||||
@@ -108,8 +108,15 @@ export default {
|
|||||||
'gpuservice.instance.gpuCount.noAvailable':
|
'gpuservice.instance.gpuCount.noAvailable':
|
||||||
'没有可用的 GPU 资源,请选择其他实例类型。',
|
'没有可用的 GPU 资源,请选择其他实例类型。',
|
||||||
'gpuservice.instance.gpuCount.zero': '仅使用 CPU,用于环境准备。',
|
'gpuservice.instance.gpuCount.zero': '仅使用 CPU,用于环境准备。',
|
||||||
|
'gpuservice.instance.mode.whole': '整卡',
|
||||||
|
'gpuservice.instance.mode.sliced': '按比例',
|
||||||
|
'gpuservice.instance.slice.memoryPercentage': '显存占比(%)',
|
||||||
|
'gpuservice.instance.slice.fullCores': '100% 算力',
|
||||||
|
'gpuservice.instance.slice.percentage.required': '请选择或输入百分比',
|
||||||
|
'gpuservice.instance.slice.percentage.max': '比例需在 1% 到 {count}% 之间',
|
||||||
'gpuservice.instance.stock': '库存',
|
'gpuservice.instance.stock': '库存',
|
||||||
'gpuservice.instance.sliced': '切分',
|
'gpuservice.instance.sliced': '切分',
|
||||||
|
'gpuservice.instance.sliceable': '可切分',
|
||||||
'gpuservice.instance.memory': '显存',
|
'gpuservice.instance.memory': '显存',
|
||||||
'gpuservice.instance.ram': '内存',
|
'gpuservice.instance.ram': '内存',
|
||||||
'gpuservice.instance.os': '系统',
|
'gpuservice.instance.os': '系统',
|
||||||
|
|||||||
@@ -23,6 +23,11 @@ interface NumberSelectionProps {
|
|||||||
labelExtra?: React.ReactNode;
|
labelExtra?: React.ReactNode;
|
||||||
maxCount?: number;
|
maxCount?: number;
|
||||||
tips?: string;
|
tips?: string;
|
||||||
|
// Explicit preset tick values (e.g. [10,20,...,100] for percentage slicing).
|
||||||
|
// Overrides the default 1..maxCount sequence.
|
||||||
|
presetValues?: number[];
|
||||||
|
// Force the free-input box to show regardless of max/maxCount.
|
||||||
|
alwaysShowInput?: boolean;
|
||||||
onChange?: (value: number) => void;
|
onChange?: (value: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -39,17 +44,18 @@ const NumberSelection: React.FC<NumberSelectionProps> = ({
|
|||||||
className,
|
className,
|
||||||
maxCount = 8,
|
maxCount = 8,
|
||||||
tips,
|
tips,
|
||||||
|
presetValues,
|
||||||
|
alwaysShowInput,
|
||||||
style,
|
style,
|
||||||
onChange
|
onChange
|
||||||
}) => {
|
}) => {
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
|
|
||||||
const showCustomInput = max > maxCount;
|
const showCustomInput = alwaysShowInput || max > maxCount;
|
||||||
const presetItems = Array.from(
|
const presetItems =
|
||||||
{ length: Math.max(0, maxCount) },
|
presetValues ??
|
||||||
(_, i) => i + 1
|
Array.from({ length: Math.max(0, maxCount) }, (_, i) => i + 1);
|
||||||
);
|
if (!presetValues && min <= 0) {
|
||||||
if (min <= 0) {
|
|
||||||
presetItems.unshift(0);
|
presetItems.unshift(0);
|
||||||
}
|
}
|
||||||
const items = presetItems;
|
const items = presetItems;
|
||||||
|
|||||||
@@ -39,9 +39,20 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 0 12px;
|
padding: 0 12px;
|
||||||
|
padding-right: 2px;
|
||||||
color: var(--ant-color-text-tertiary);
|
color: var(--ant-color-text-tertiary);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
padding-block: 6px;
|
padding-block: 6px;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
// Spread the label and its labelExtra (e.g. the whole/sliced Segmented)
|
||||||
|
// to opposite ends of the row.
|
||||||
|
:global(.label-text) {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.contentWrapper {
|
.contentWrapper {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|||||||
@@ -59,6 +59,9 @@ interface InstanceTypeItemProps {
|
|||||||
|
|
||||||
interface MetadataSectionProps {
|
interface MetadataSectionProps {
|
||||||
spec: InstanceTypeItemModel['spec'];
|
spec: InstanceTypeItemModel['spec'];
|
||||||
|
// status.onceMaxRequest.acceleratorSliced (max sliceable percentage). Shown
|
||||||
|
// next to Max for sliceable types.
|
||||||
|
slicedMaxPercentage?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const MetaItem: React.FC<{
|
const MetaItem: React.FC<{
|
||||||
@@ -125,7 +128,8 @@ function getInstanceDerived(item: InstanceTypeItemModel) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const InstanceMetadataSection: React.FC<MetadataSectionProps> = ({
|
export const InstanceMetadataSection: React.FC<MetadataSectionProps> = ({
|
||||||
spec
|
spec,
|
||||||
|
slicedMaxPercentage
|
||||||
}) => {
|
}) => {
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
|
|
||||||
@@ -133,6 +137,14 @@ export const InstanceMetadataSection: React.FC<MetadataSectionProps> = ({
|
|||||||
spec
|
spec
|
||||||
} as InstanceTypeItemModel);
|
} as InstanceTypeItemModel);
|
||||||
|
|
||||||
|
// Sliceable types show "Max {n} · Sliceable {m}%" instead of just the count.
|
||||||
|
const showSliceable = !!spec.sliceable && (slicedMaxPercentage ?? 0) > 0;
|
||||||
|
const maxValue = showSliceable
|
||||||
|
? `${spec.maxComputeUnitCount || 0} · ${intl.formatMessage({
|
||||||
|
id: 'gpuservice.instance.sliceable'
|
||||||
|
})} ${slicedMaxPercentage}%`
|
||||||
|
: `${spec.maxComputeUnitCount || 0}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Meta $columns={isGPU ? 11 : 7}>
|
<Meta $columns={isGPU ? 11 : 7}>
|
||||||
{isGPU && (
|
{isGPU && (
|
||||||
@@ -159,7 +171,7 @@ export const InstanceMetadataSection: React.FC<MetadataSectionProps> = ({
|
|||||||
},
|
},
|
||||||
{ count: '' }
|
{ count: '' }
|
||||||
)}
|
)}
|
||||||
value={`${spec.maxComputeUnitCount || 0}`}
|
value={maxValue}
|
||||||
/>
|
/>
|
||||||
{/* row 2: OS | Arch | CPU */}
|
{/* row 2: OS | Arch | CPU */}
|
||||||
<MetaItem
|
<MetaItem
|
||||||
@@ -278,7 +290,12 @@ const InstanceTypeItem: React.FC<InstanceTypeItemProps> = ({ item }) => {
|
|||||||
/>
|
/>
|
||||||
</Flex>
|
</Flex>
|
||||||
</Title>
|
</Title>
|
||||||
<InstanceMetadataSection spec={specData}></InstanceMetadataSection>
|
<InstanceMetadataSection
|
||||||
|
spec={specData}
|
||||||
|
slicedMaxPercentage={
|
||||||
|
Number(item.status?.onceMaxRequest?.acceleratorSliced) || 0
|
||||||
|
}
|
||||||
|
></InstanceMetadataSection>
|
||||||
</Flex>
|
</Flex>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -262,15 +262,15 @@ export const getAcceleratorMax = (
|
|||||||
|
|
||||||
// Picks the candidate (cluster + type name) that should fulfill a requested
|
// Picks the candidate (cluster + type name) that should fulfill a requested
|
||||||
// accelerator count: the first candidate of the smallest tier whose
|
// accelerator count: the first candidate of the smallest tier whose
|
||||||
// onceMaxRequest.accelerator is >= the requested count and whose cpu/ram/localStorage
|
// onceMaxRequest.accelerator is >= the requested count. Accelerated types are
|
||||||
// remaining are all > 0.
|
// not gated on CPU remaining (only CPU-only types are); in sliced mode the
|
||||||
|
// candidate's acceleratorSliced remaining must also be > 0.
|
||||||
export const pickCandidateForAccelerator = <
|
export const pickCandidateForAccelerator = <
|
||||||
C extends {
|
C extends {
|
||||||
cluster: string;
|
cluster: string;
|
||||||
name: string;
|
name: string;
|
||||||
cpu?: { remaining?: string | null } | null;
|
cpu?: { remaining?: string | null } | null;
|
||||||
ram?: { remaining?: string | null } | null;
|
acceleratorSliced?: { remaining?: string | null } | null;
|
||||||
localStorage?: { remaining?: string | null } | null;
|
|
||||||
}
|
}
|
||||||
>(
|
>(
|
||||||
tiers:
|
tiers:
|
||||||
@@ -280,14 +280,21 @@ export const pickCandidateForAccelerator = <
|
|||||||
}[]
|
}[]
|
||||||
| undefined
|
| undefined
|
||||||
| null,
|
| null,
|
||||||
{ count, acceleratable }: { count: number; acceleratable?: boolean }
|
{
|
||||||
|
count,
|
||||||
|
acceleratable,
|
||||||
|
sliced
|
||||||
|
}: { count: number; acceleratable?: boolean; sliced?: boolean }
|
||||||
): C | null => {
|
): C | null => {
|
||||||
if (!tiers?.length) return null;
|
if (!tiers?.length) return null;
|
||||||
|
|
||||||
const hasResources = (c: C) =>
|
const hasResources = (c: C) => {
|
||||||
parseQuantity(c.cpu?.remaining) > 0 &&
|
// Accelerated types are not gated on CPU remaining; CPU-only types are.
|
||||||
parseQuantity(c.ram?.remaining) > 0 &&
|
if (!acceleratable && parseQuantity(c.cpu?.remaining) <= 0) return false;
|
||||||
parseQuantity(c.localStorage?.remaining) > 0;
|
if (sliced && parseQuantity(c.acceleratorSliced?.remaining) <= 0)
|
||||||
|
return false;
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
const sorted = [...tiers].sort(
|
const sorted = [...tiers].sort(
|
||||||
(a, b) =>
|
(a, b) =>
|
||||||
|
|||||||
@@ -45,6 +45,11 @@ export interface FormData {
|
|||||||
ram: string | null | number;
|
ram: string | null | number;
|
||||||
localStorage: string | null | number;
|
localStorage: string | null | number;
|
||||||
accelerator: number | string | null;
|
accelerator: number | string | null;
|
||||||
|
// Sliced (percentage) mode only. Memory (VRAM) percentage bound to the
|
||||||
|
// 10-100 selector + free input; cores (compute) percentage bound to the
|
||||||
|
// "100% compute" checkbox (100 when checked, mirrors memory otherwise).
|
||||||
|
acceleratorSlicedMemoryPercentage?: number;
|
||||||
|
acceleratorSlicedCoresPercentage?: number;
|
||||||
};
|
};
|
||||||
volume: {
|
volume: {
|
||||||
ephemeral?: {
|
ephemeral?: {
|
||||||
@@ -128,8 +133,10 @@ export interface InstanceTypeCandidate {
|
|||||||
name: string;
|
name: string;
|
||||||
accelerator: InstanceTypeResource;
|
accelerator: InstanceTypeResource;
|
||||||
cpu: InstanceTypeResource;
|
cpu: InstanceTypeResource;
|
||||||
ram: InstanceTypeResource;
|
// Shared-mode available resource (not shown in the GPU Instance form).
|
||||||
localStorage: InstanceTypeResource;
|
acceleratorShared: InstanceTypeResource;
|
||||||
|
// Sliced-mode available resource.
|
||||||
|
acceleratorSliced: InstanceTypeResource;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface InstanceTypeTierOnceMaxRequestResource {
|
export interface InstanceTypeTierOnceMaxRequestResource {
|
||||||
@@ -149,6 +156,8 @@ export interface InstanceTypeOnceMaxRequestResource {
|
|||||||
cpu: QuanityCPU;
|
cpu: QuanityCPU;
|
||||||
ram: QuanityMemory;
|
ram: QuanityMemory;
|
||||||
localStorage: QuanityLocalStorage;
|
localStorage: QuanityLocalStorage;
|
||||||
|
acceleratorShared: `${number}` | null;
|
||||||
|
acceleratorSliced: `${number}` | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CPUCache {
|
export interface CPUCache {
|
||||||
@@ -181,6 +190,8 @@ export interface InstanceTypeSpec {
|
|||||||
family?: string | null;
|
family?: string | null;
|
||||||
computeCapability?: string | null;
|
computeCapability?: string | null;
|
||||||
sliced?: string | null;
|
sliced?: string | null;
|
||||||
|
sliceable?: boolean;
|
||||||
|
localStorage?: QuanityLocalStorage;
|
||||||
maxComputeUnitCount?: number;
|
maxComputeUnitCount?: number;
|
||||||
unitResources?: {
|
unitResources?: {
|
||||||
cpu: QuanityCPU;
|
cpu: QuanityCPU;
|
||||||
|
|||||||
@@ -273,33 +273,94 @@ const GPUServiceInstanceForm: React.FC<InstanceFormProps> = forwardRef(
|
|||||||
|
|
||||||
const buildResourcesDataForSubmit = (values: FormData) => {
|
const buildResourcesDataForSubmit = (values: FormData) => {
|
||||||
const unitResourcesParsed = getUnitResources();
|
const unitResourcesParsed = getUnitResources();
|
||||||
const accelerator = _.toNumber(values.spec?.resources?.accelerator) || 0;
|
const resources = values.spec?.resources ?? ({} as any);
|
||||||
const cpuCount = _.toNumber(values.spec?.resources?.cpu) || 0;
|
const accelerator = _.toNumber(resources.accelerator) || 0;
|
||||||
|
const cpuCount = _.toNumber(resources.cpu) || 0;
|
||||||
|
|
||||||
const cpuNum = unitResourcesParsed?.cpu?.num;
|
const cpuNum = unitResourcesParsed?.cpu?.num;
|
||||||
const ramNum = unitResourcesParsed?.ram?.num;
|
const ramNum = unitResourcesParsed?.ram?.num;
|
||||||
|
|
||||||
const fallbackCpu = values.spec?.resources?.cpu;
|
const fallbackCpu = resources.cpu;
|
||||||
|
|
||||||
const factor = isGPUType ? accelerator : cpuCount;
|
// Sliced mode: scale a single card's unit resources by the chosen
|
||||||
|
// percentage (floored) — both CPU and RAM. Whole/CPU mode: multiply the
|
||||||
|
// unit by the count.
|
||||||
|
const percentage = _.toNumber(
|
||||||
|
resources.acceleratorSlicedMemoryPercentage
|
||||||
|
);
|
||||||
|
const sliced = isGPUType && percentage > 0;
|
||||||
|
const wholeFactor = isGPUType ? accelerator : cpuCount;
|
||||||
|
|
||||||
|
const scale = (num: number, unit: string) =>
|
||||||
|
sliced
|
||||||
|
? `${Math.max(1, _.floor((num * percentage) / 100))}${unit}`
|
||||||
|
: `${wholeFactor * num}${unit}`;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
cpu: cpuNum
|
cpu: cpuNum
|
||||||
? `${factor * cpuNum}${unitResourcesParsed?.cpu?.unit || ''}`
|
? scale(cpuNum, unitResourcesParsed?.cpu?.unit || '')
|
||||||
: // Don't stringify an unset value — `${undefined}` becomes the
|
: // Don't stringify an unset value — `${undefined}` becomes the
|
||||||
// literal "undefined", which fails k8s quantity validation.
|
// literal "undefined", which fails k8s quantity validation.
|
||||||
fallbackCpu
|
fallbackCpu
|
||||||
? `${fallbackCpu}`
|
? `${fallbackCpu}`
|
||||||
: undefined,
|
: undefined,
|
||||||
ram: ramNum
|
ram: ramNum
|
||||||
? `${factor * ramNum}${unitResourcesParsed?.ram?.unit || ''}`
|
? scale(ramNum, unitResourcesParsed?.ram?.unit || '')
|
||||||
: values.spec?.resources?.ram
|
: resources.ram
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Sliced display: set the (disabled) CPU / RAM inputs to a single card's
|
||||||
|
// unit resources scaled by the chosen percentage, floored. Reads the
|
||||||
|
// percentage straight from the form so it can be re-run after any slider
|
||||||
|
// change without threading values through.
|
||||||
|
const applySlicedResourceScaling = () => {
|
||||||
|
const unitResourcesParsed = getUnitResources();
|
||||||
|
const cpuCores = unitResourcesParsed?.cpu?.cores;
|
||||||
|
const ramValue = unitResourcesParsed?.ram?.value;
|
||||||
|
const percentage = _.toNumber(
|
||||||
|
form.getFieldValue([
|
||||||
|
'spec',
|
||||||
|
'resources',
|
||||||
|
'acceleratorSlicedMemoryPercentage'
|
||||||
|
])
|
||||||
|
);
|
||||||
|
|
||||||
|
form.setFieldsValue({
|
||||||
|
spec: {
|
||||||
|
resources: {
|
||||||
|
cpu:
|
||||||
|
cpuCores != null && percentage > 0
|
||||||
|
? Math.max(1, _.floor((cpuCores * percentage) / 100))
|
||||||
|
: null,
|
||||||
|
ram:
|
||||||
|
ramValue != null && percentage > 0
|
||||||
|
? Math.max(1, _.floor((ramValue * percentage) / 100))
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} as any);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Single entry point for the sliced memory ratio: write the ratio (compute
|
||||||
|
// stays pinned at 100%) and rescale CPU / RAM off it. Reused by the slider
|
||||||
|
// onChange and by the sliced-mode defaults so both share one path.
|
||||||
|
const applySliceMemoryPercentage = (value: number) => {
|
||||||
|
form.setFieldsValue({
|
||||||
|
spec: {
|
||||||
|
resources: {
|
||||||
|
acceleratorSlicedMemoryPercentage: value,
|
||||||
|
acceleratorSlicedCoresPercentage: 100
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} as any);
|
||||||
|
applySlicedResourceScaling();
|
||||||
|
};
|
||||||
|
|
||||||
const resolveAndApply = (
|
const resolveAndApply = (
|
||||||
instanceType: InstanceTypeItem | undefined,
|
instanceType: InstanceTypeItem | undefined,
|
||||||
count: number
|
count: number,
|
||||||
|
sliced?: boolean
|
||||||
) => {
|
) => {
|
||||||
if (!instanceType) {
|
if (!instanceType) {
|
||||||
setSelectedInstanceType(undefined);
|
setSelectedInstanceType(undefined);
|
||||||
@@ -328,7 +389,8 @@ const GPUServiceInstanceForm: React.FC<InstanceFormProps> = forwardRef(
|
|||||||
instanceType.status?.tiers,
|
instanceType.status?.tiers,
|
||||||
{
|
{
|
||||||
count,
|
count,
|
||||||
acceleratable: instanceType.spec?.acceleratable
|
acceleratable: instanceType.spec?.acceleratable,
|
||||||
|
sliced
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -336,9 +398,12 @@ const GPUServiceInstanceForm: React.FC<InstanceFormProps> = forwardRef(
|
|||||||
|
|
||||||
setOnceMaxRequest({
|
setOnceMaxRequest({
|
||||||
cpu: ceilMilliToCore(candidate?.cpu?.onceMaxRequest)?.cores,
|
cpu: ceilMilliToCore(candidate?.cpu?.onceMaxRequest)?.cores,
|
||||||
memory: parseQuantityToGi(candidate?.ram?.onceMaxRequest)?.value,
|
// candidate no longer carries ram/localStorage: memory max comes from
|
||||||
localStorage: parseQuantityToGi(candidate?.localStorage?.onceMaxRequest)
|
// the type-level onceMaxRequest.ram (already parsed to a Gi number by
|
||||||
?.value
|
// the query hook), disk max from spec.localStorage (UI-only cap).
|
||||||
|
memory: _.toNumber(instanceType.status?.onceMaxRequest?.ram) || null,
|
||||||
|
localStorage:
|
||||||
|
parseQuantityToGi(instanceType.spec?.localStorage)?.value ?? null
|
||||||
});
|
});
|
||||||
|
|
||||||
form.setFieldsValue({
|
form.setFieldsValue({
|
||||||
@@ -354,8 +419,44 @@ const GPUServiceInstanceForm: React.FC<InstanceFormProps> = forwardRef(
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Whole-card (exclusive) vs sliced (percentage) mode. Only meaningful for
|
||||||
|
// sliceable accelerator types; derived (no persisted field) — on edit/
|
||||||
|
// recreate it is inferred from acceleratorSlicedMemoryPercentage > 0.
|
||||||
|
const [sliceMode, setSliceMode] = useState<'whole' | 'sliced'>('whole');
|
||||||
|
|
||||||
const handleAcceleratorChange = (count: number) => {
|
const handleAcceleratorChange = (count: number) => {
|
||||||
resolveAndApply(selectedInstanceType, count);
|
resolveAndApply(selectedInstanceType, count, false);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Seed the sliced-mode default ratio for an instance type: 10% but never
|
||||||
|
// above the type's max sliceable ratio (status.onceMaxRequest
|
||||||
|
// .acceleratorSliced). Shares applySliceMemoryPercentage with the slider.
|
||||||
|
const applySlicedDefaults = (instanceType?: InstanceTypeItem) => {
|
||||||
|
const slicedMax =
|
||||||
|
_.toNumber(instanceType?.status?.onceMaxRequest?.acceleratorSliced) ||
|
||||||
|
0;
|
||||||
|
applySliceMemoryPercentage(slicedMax ? Math.min(10, slicedMax) : 10);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Toggle between whole-card and sliced mode. Sliced fixes the accelerator
|
||||||
|
// count to 1 (a single card is partitioned by percentage) and clears the
|
||||||
|
// slice-percentage fields when leaving sliced mode.
|
||||||
|
const handleSliceModeChange = (mode: 'whole' | 'sliced') => {
|
||||||
|
setSliceMode(mode);
|
||||||
|
if (mode === 'sliced') {
|
||||||
|
resolveAndApply(selectedInstanceType, 1, true);
|
||||||
|
applySlicedDefaults(selectedInstanceType);
|
||||||
|
} else {
|
||||||
|
form.setFieldsValue({
|
||||||
|
spec: {
|
||||||
|
resources: {
|
||||||
|
acceleratorSlicedMemoryPercentage: undefined,
|
||||||
|
acceleratorSlicedCoresPercentage: undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} as any);
|
||||||
|
resolveAndApply(selectedInstanceType, 1, false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const onTargetChange = (key: string) => {
|
const onTargetChange = (key: string) => {
|
||||||
@@ -407,6 +508,14 @@ const GPUServiceInstanceForm: React.FC<InstanceFormProps> = forwardRef(
|
|||||||
? _.toNumber(currentData?.spec?.resources?.accelerator)
|
? _.toNumber(currentData?.spec?.resources?.accelerator)
|
||||||
: _.toNumber(currentData?.spec?.resources?.cpu) || 0;
|
: _.toNumber(currentData?.spec?.resources?.cpu) || 0;
|
||||||
|
|
||||||
|
// Infer the mode from the persisted slice percentage (recreate keeps
|
||||||
|
// the section editable; edit/view render a readonly card).
|
||||||
|
const persistedSliced =
|
||||||
|
_.toNumber(
|
||||||
|
currentData?.spec?.resources?.acceleratorSlicedMemoryPercentage
|
||||||
|
) > 0;
|
||||||
|
setSliceMode(persistedSliced ? 'sliced' : 'whole');
|
||||||
|
|
||||||
form.setFieldsValue({
|
form.setFieldsValue({
|
||||||
...currentData,
|
...currentData,
|
||||||
spec: {
|
spec: {
|
||||||
@@ -424,6 +533,12 @@ const GPUServiceInstanceForm: React.FC<InstanceFormProps> = forwardRef(
|
|||||||
enable_ssh: !!currentData?.spec?.sshPublicKeys?.length,
|
enable_ssh: !!currentData?.spec?.sshPublicKeys?.length,
|
||||||
storageMode: detectMode(currentData?.spec?.volume)
|
storageMode: detectMode(currentData?.spec?.volume)
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// buildResourcesData above filled CPU / RAM for the whole card; rescale
|
||||||
|
// them off the persisted percentages when recreating a sliced instance.
|
||||||
|
if (persistedSliced) {
|
||||||
|
applySlicedResourceScaling();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [action, currentData, form, open, realAction, instanceTypeList]);
|
}, [action, currentData, form, open, realAction, instanceTypeList]);
|
||||||
|
|
||||||
@@ -492,11 +607,30 @@ const GPUServiceInstanceForm: React.FC<InstanceFormProps> = forwardRef(
|
|||||||
getFieldsValue: () => form.getFieldsValue(),
|
getFieldsValue: () => form.getFieldsValue(),
|
||||||
applyInstanceType: (instanceType?: InstanceTypeItem) => {
|
applyInstanceType: (instanceType?: InstanceTypeItem) => {
|
||||||
if (!instanceType) {
|
if (!instanceType) {
|
||||||
|
setSliceMode('whole');
|
||||||
resolveAndApply(undefined, 0);
|
resolveAndApply(undefined, 0);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// set default to 1, for all instance types: GPU or non-GPU
|
// A sliceable type with no whole-card capacity (Max < 1) defaults to
|
||||||
|
// sliced mode — whole mode would have nothing selectable.
|
||||||
|
const wholeMax = instanceType.spec?.maxComputeUnitCount ?? 0;
|
||||||
|
const slicedMax =
|
||||||
|
_.toNumber(instanceType.status?.onceMaxRequest?.acceleratorSliced) ||
|
||||||
|
0;
|
||||||
|
const defaultSliced =
|
||||||
|
!!instanceType.spec?.sliceable && wholeMax < 1 && slicedMax > 0;
|
||||||
|
|
||||||
|
if (defaultSliced) {
|
||||||
|
setSliceMode('sliced');
|
||||||
|
resolveAndApply(instanceType, 1, true);
|
||||||
|
applySlicedDefaults(instanceType);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise default to whole-card mode (a new type may not be
|
||||||
|
// sliceable); set count to 1 for all instance types: GPU or non-GPU.
|
||||||
|
setSliceMode('whole');
|
||||||
resolveAndApply(instanceType, 1);
|
resolveAndApply(instanceType, 1);
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
@@ -607,6 +741,9 @@ const GPUServiceInstanceForm: React.FC<InstanceFormProps> = forwardRef(
|
|||||||
currentData={currentData as any}
|
currentData={currentData as any}
|
||||||
onceMaxRequest={onceMaxRequest}
|
onceMaxRequest={onceMaxRequest}
|
||||||
noAvailableTypes={noAvailableInstanceTypes}
|
noAvailableTypes={noAvailableInstanceTypes}
|
||||||
|
sliceMode={sliceMode}
|
||||||
|
onSliceModeChange={handleSliceModeChange}
|
||||||
|
onSliceMemoryPercentageChange={applySliceMemoryPercentage}
|
||||||
onGPUCountChange={handleAcceleratorChange}
|
onGPUCountChange={handleAcceleratorChange}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { PageActionType } from '@/config/types';
|
|||||||
import NumberSelection from '@/pages/_components/number-selection';
|
import NumberSelection from '@/pages/_components/number-selection';
|
||||||
import { InputNumber } from '@gpustack/core-ui';
|
import { InputNumber } from '@gpustack/core-ui';
|
||||||
import { useIntl } from '@umijs/max';
|
import { useIntl } from '@umijs/max';
|
||||||
import { Flex, Form } from 'antd';
|
import { Flex, Form, Segmented } from 'antd';
|
||||||
import _ from 'lodash';
|
import _ from 'lodash';
|
||||||
import { useContext, useMemo } from 'react';
|
import { useContext, useMemo } from 'react';
|
||||||
import styled from 'styled-components';
|
import styled from 'styled-components';
|
||||||
@@ -57,6 +57,9 @@ const InstanceTypePicker: React.FC<InstanceTypePickerProps> = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Fixed 10-tick percentage scale (10..100) for the sliced (percentage) mode.
|
||||||
|
const SLICE_PERCENT_TICKS = [10, 20, 30, 40, 50, 60, 70, 80, 90, 100];
|
||||||
|
|
||||||
interface InstanceTypeFormItemProps {
|
interface InstanceTypeFormItemProps {
|
||||||
action: PageActionType;
|
action: PageActionType;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
@@ -67,6 +70,12 @@ interface InstanceTypeFormItemProps {
|
|||||||
// org owns no clusters. Surface a "no available" message instead of the
|
// org owns no clusters. Surface a "no available" message instead of the
|
||||||
// "please select" placeholder + empty CPU / memory inputs.
|
// "please select" placeholder + empty CPU / memory inputs.
|
||||||
noAvailableTypes?: boolean;
|
noAvailableTypes?: boolean;
|
||||||
|
// Whole-card (exclusive) vs sliced (percentage) mode. Owned by the parent
|
||||||
|
// form (it drives candidate picking + the fixed accelerator=1 for sliced).
|
||||||
|
sliceMode?: 'whole' | 'sliced';
|
||||||
|
onSliceModeChange?: (mode: 'whole' | 'sliced') => void;
|
||||||
|
// Commit a new sliced memory ratio (writes the field + rescales CPU / RAM).
|
||||||
|
onSliceMemoryPercentageChange?: (value: number) => void;
|
||||||
onGPUCountChange?: (value: number) => void;
|
onGPUCountChange?: (value: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,9 +86,13 @@ const InstanceTypeFormItem: React.FC<InstanceTypeFormItemProps> = ({
|
|||||||
selectedInstanceType,
|
selectedInstanceType,
|
||||||
onceMaxRequest,
|
onceMaxRequest,
|
||||||
noAvailableTypes,
|
noAvailableTypes,
|
||||||
|
sliceMode = 'whole',
|
||||||
|
onSliceModeChange,
|
||||||
|
onSliceMemoryPercentageChange,
|
||||||
onGPUCountChange
|
onGPUCountChange
|
||||||
}) => {
|
}) => {
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
|
const form = Form.useFormInstance();
|
||||||
const { isGPUType } = useContext(FormContext);
|
const { isGPUType } = useContext(FormContext);
|
||||||
|
|
||||||
const maxComputeUnitCount = useMemo(() => {
|
const maxComputeUnitCount = useMemo(() => {
|
||||||
@@ -104,6 +117,60 @@ const InstanceTypeFormItem: React.FC<InstanceTypeFormItemProps> = ({
|
|||||||
onGPUCountChange?.(value);
|
onGPUCountChange?.(value);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Sliced mode is only offered for sliceable accelerator types, and only when
|
||||||
|
// the section is editable (create / recreate; edit renders a readonly card).
|
||||||
|
const showModeSwitch =
|
||||||
|
action !== PageAction.EDIT &&
|
||||||
|
isGPUType &&
|
||||||
|
!!selectedInstanceType?.spec?.sliceable;
|
||||||
|
|
||||||
|
const handleModeChange = (value: string) => {
|
||||||
|
onSliceModeChange?.(value as 'whole' | 'sliced');
|
||||||
|
};
|
||||||
|
|
||||||
|
// Memory (VRAM) percentage changed via the slider/input — forward the new
|
||||||
|
// value so the parent writes the field and rescales CPU / RAM.
|
||||||
|
const handleMemoryPercentageChange = (value: number) => {
|
||||||
|
onSliceMemoryPercentageChange?.(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const modeSegmented = showModeSwitch ? (
|
||||||
|
<Segmented
|
||||||
|
size="small"
|
||||||
|
shape="round"
|
||||||
|
style={{ fontSize: 12 }}
|
||||||
|
value={sliceMode}
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={handleModeChange}
|
||||||
|
options={[
|
||||||
|
{
|
||||||
|
label: intl.formatMessage({ id: 'gpuservice.instance.mode.whole' }),
|
||||||
|
value: 'whole'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: intl.formatMessage({ id: 'gpuservice.instance.mode.sliced' }),
|
||||||
|
value: 'sliced'
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
) : null;
|
||||||
|
|
||||||
|
const isSliced = showModeSwitch && sliceMode === 'sliced';
|
||||||
|
|
||||||
|
// Max selectable ratio in sliced mode: status.onceMaxRequest.acceleratorSliced
|
||||||
|
// (a percentage). Ticks above it stay visible but disabled.
|
||||||
|
const slicedMaxPercentage =
|
||||||
|
_.toNumber(
|
||||||
|
selectedInstanceType?.status?.onceMaxRequest?.acceleratorSliced
|
||||||
|
) || 0;
|
||||||
|
|
||||||
|
// When the max ratio is below 10%, switch the ticks to a finer 1..10 scale
|
||||||
|
// so small slices are still selectable; otherwise use the 10..100 scale.
|
||||||
|
const sliceTicks =
|
||||||
|
slicedMaxPercentage < 10
|
||||||
|
? [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
|
||||||
|
: SLICE_PERCENT_TICKS;
|
||||||
|
|
||||||
const renderMaxLabel = (
|
const renderMaxLabel = (
|
||||||
label: React.ReactNode,
|
label: React.ReactNode,
|
||||||
max?: number | null
|
max?: number | null
|
||||||
@@ -206,7 +273,7 @@ const InstanceTypeFormItem: React.FC<InstanceTypeFormItemProps> = ({
|
|||||||
: ['spec', 'resources', 'cpu']
|
: ['spec', 'resources', 'cpu']
|
||||||
}
|
}
|
||||||
preserve
|
preserve
|
||||||
hidden={action === PageAction.EDIT}
|
hidden={action === PageAction.EDIT || isSliced}
|
||||||
normalize={(value) => (value != null ? _.toString(value) : undefined)}
|
normalize={(value) => (value != null ? _.toString(value) : undefined)}
|
||||||
getValueProps={(value) => ({
|
getValueProps={(value) => ({
|
||||||
value: value != null ? _.toNumber(value) : undefined
|
value: value != null ? _.toNumber(value) : undefined
|
||||||
@@ -247,6 +314,7 @@ const InstanceTypeFormItem: React.FC<InstanceTypeFormItemProps> = ({
|
|||||||
max={maxComputeUnitCount}
|
max={maxComputeUnitCount}
|
||||||
step={1}
|
step={1}
|
||||||
required
|
required
|
||||||
|
labelExtra={sliceMode === 'whole' ? modeSegmented : undefined}
|
||||||
disabled={disabled || action === PageAction.EDIT}
|
disabled={disabled || action === PageAction.EDIT}
|
||||||
label={`${intl.formatMessage({ id: 'common.max.count' }, { label: numberSelectionLabel.label })} (${intl.formatMessage(
|
label={`${intl.formatMessage({ id: 'common.max.count' }, { label: numberSelectionLabel.label })} (${intl.formatMessage(
|
||||||
{
|
{
|
||||||
@@ -257,6 +325,89 @@ const InstanceTypeFormItem: React.FC<InstanceTypeFormItemProps> = ({
|
|||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
)}
|
)}
|
||||||
|
{!noAvailableTypes && isSliced && (
|
||||||
|
<FieldBlock>
|
||||||
|
<Form.Item<FormData>
|
||||||
|
name={['spec', 'resources', 'acceleratorSlicedMemoryPercentage']}
|
||||||
|
getValueProps={(value) => ({
|
||||||
|
value: value != null ? _.toNumber(value) : undefined
|
||||||
|
})}
|
||||||
|
rules={[
|
||||||
|
{
|
||||||
|
required: true,
|
||||||
|
validator: (_, value) => {
|
||||||
|
const num = Number(value);
|
||||||
|
if (value == null || value === '' || Number.isNaN(num)) {
|
||||||
|
return Promise.reject(
|
||||||
|
new Error(
|
||||||
|
intl.formatMessage({
|
||||||
|
id: 'gpuservice.instance.slice.percentage.required'
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (num > slicedMaxPercentage || num <= 0) {
|
||||||
|
return Promise.reject(
|
||||||
|
new Error(
|
||||||
|
intl.formatMessage(
|
||||||
|
{
|
||||||
|
id: 'gpuservice.instance.slice.percentage.max'
|
||||||
|
},
|
||||||
|
{ count: slicedMaxPercentage }
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<NumberSelection
|
||||||
|
min={1}
|
||||||
|
max={slicedMaxPercentage}
|
||||||
|
step={1}
|
||||||
|
maxCount={sliceTicks.length}
|
||||||
|
presetValues={sliceTicks}
|
||||||
|
alwaysShowInput
|
||||||
|
required
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={handleMemoryPercentageChange}
|
||||||
|
labelExtra={modeSegmented}
|
||||||
|
label={intl.formatMessage({
|
||||||
|
id: 'gpuservice.instance.slice.memoryPercentage'
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
{/* Compute (cores) percentage is fixed at 100. Kept in the form via a
|
||||||
|
hidden item so it rides along on submit. */}
|
||||||
|
<Form.Item<FormData>
|
||||||
|
name={['spec', 'resources', 'acceleratorSlicedCoresPercentage']}
|
||||||
|
hidden
|
||||||
|
>
|
||||||
|
<InputNumber />
|
||||||
|
</Form.Item>
|
||||||
|
</FieldBlock>
|
||||||
|
)}
|
||||||
|
{/* Edit renders a readonly card (no sliced UI), so register the slice
|
||||||
|
percentages as hidden fields — otherwise their persisted values are
|
||||||
|
dropped from the submit payload. */}
|
||||||
|
{action === PageAction.EDIT && (
|
||||||
|
<>
|
||||||
|
<Form.Item<FormData>
|
||||||
|
name={['spec', 'resources', 'acceleratorSlicedMemoryPercentage']}
|
||||||
|
hidden
|
||||||
|
>
|
||||||
|
<InputNumber />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item<FormData>
|
||||||
|
name={['spec', 'resources', 'acceleratorSlicedCoresPercentage']}
|
||||||
|
hidden
|
||||||
|
>
|
||||||
|
<InputNumber />
|
||||||
|
</Form.Item>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
{!noAvailableTypes && (
|
{!noAvailableTypes && (
|
||||||
<Flex gap={12}>
|
<Flex gap={12}>
|
||||||
<div style={{ flex: 1 }}>
|
<div style={{ flex: 1 }}>
|
||||||
@@ -281,11 +432,7 @@ const InstanceTypeFormItem: React.FC<InstanceTypeFormItemProps> = ({
|
|||||||
key="cpu_input"
|
key="cpu_input"
|
||||||
preserve
|
preserve
|
||||||
>
|
>
|
||||||
<InputNumber
|
<InputNumber label={'CPU'} disabled={true} />
|
||||||
label={'CPU'}
|
|
||||||
max={onceMaxRequest?.cpu ?? undefined}
|
|
||||||
disabled={true}
|
|
||||||
/>
|
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -36,6 +36,20 @@ export default function useQueryInstanceTypes() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const max = getAcceleratorMax(item.status?.tiers);
|
const max = getAcceleratorMax(item.status?.tiers);
|
||||||
|
|
||||||
|
// Sliceable types stay selectable as long as either whole-card or sliced
|
||||||
|
// capacity remains; unavailable only when both status.onceMaxRequest
|
||||||
|
// .accelerator and .acceleratorSliced are 0.
|
||||||
|
if (item.spec?.sliceable) {
|
||||||
|
const wholeMax = Number(item.status?.onceMaxRequest?.accelerator) || 0;
|
||||||
|
const slicedMax =
|
||||||
|
Number(item.status?.onceMaxRequest?.acceleratorSliced) || 0;
|
||||||
|
return {
|
||||||
|
maxComputeUnitCount: max || 0,
|
||||||
|
available: wholeMax > 0 || slicedMax > 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
maxComputeUnitCount: max || 0,
|
maxComputeUnitCount: max || 0,
|
||||||
available: (max || 0) > 0
|
available: (max || 0) > 0
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
* with ``buildInstanceTypeRecordFromMiB`` and feed it here.
|
* with ``buildInstanceTypeRecordFromMiB`` and feed it here.
|
||||||
*/
|
*/
|
||||||
import _ from 'lodash';
|
import _ from 'lodash';
|
||||||
import { parseJsonSafe } from '../../utils';
|
import { parseJsonSafe, parseQuantityToGi } from '../../utils';
|
||||||
import InstanceTypeCell from '../components/instance-type-cell';
|
import InstanceTypeCell from '../components/instance-type-cell';
|
||||||
import { formatMemoryDisplay } from '../config';
|
import { formatMemoryDisplay } from '../config';
|
||||||
import { InstanceTypeSpec, ListItem } from '../config/types';
|
import { InstanceTypeSpec, ListItem } from '../config/types';
|
||||||
@@ -49,6 +49,10 @@ const buildResourcesData = (
|
|||||||
return {};
|
return {};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Memory (VRAM) percentage for a sliced instance; 0 when not sliced.
|
||||||
|
const getSliceMemoryPercentage = (record: ListItem) =>
|
||||||
|
_.toNumber(record.spec?.resources?.acceleratorSlicedMemoryPercentage) || 0;
|
||||||
|
|
||||||
const formatResources = (
|
const formatResources = (
|
||||||
instanceTypeSpec: { spec: InstanceTypeSpec },
|
instanceTypeSpec: { spec: InstanceTypeSpec },
|
||||||
record: ListItem
|
record: ListItem
|
||||||
@@ -71,6 +75,34 @@ const formatResources = (
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const sliceMemoryPercentage = getSliceMemoryPercentage(record);
|
||||||
|
|
||||||
|
// Sliced: CPU / RAM carry the already-scaled values on spec.resources, and
|
||||||
|
// VRAM is the per-card memory scaled by the memory percentage (floored,
|
||||||
|
// min 1) — not the whole card's size.
|
||||||
|
if (sliceMemoryPercentage > 0) {
|
||||||
|
const vramGi = parseQuantityToGi(
|
||||||
|
(instanceTypeSpec.spec as any)?.memory
|
||||||
|
)?.value;
|
||||||
|
const vram =
|
||||||
|
vramGi != null
|
||||||
|
? `${Math.max(1, _.floor((vramGi * sliceMemoryPercentage) / 100))} GB`
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
cpu: record.spec?.resources?.cpu
|
||||||
|
? `${record.spec?.resources?.cpu} vCPU`
|
||||||
|
: '-',
|
||||||
|
ram: record.spec?.resources?.ram
|
||||||
|
? toGB(record.spec?.resources?.ram)
|
||||||
|
: '-',
|
||||||
|
vram,
|
||||||
|
localStorage: record.spec?.resources?.localStorage
|
||||||
|
? toGB(record.spec?.resources?.localStorage)
|
||||||
|
: undefined
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// VRAM = per-card GPU memory (a single card's size; not aggregated across
|
// VRAM = per-card GPU memory (a single card's size; not aggregated across
|
||||||
// cards — the model's marquee spec).
|
// cards — the model's marquee spec).
|
||||||
const vram = formatMemoryDisplay((instanceTypeSpec.spec as any)?.memory);
|
const vram = formatMemoryDisplay((instanceTypeSpec.spec as any)?.memory);
|
||||||
@@ -108,10 +140,14 @@ export const renderInstanceType = (
|
|||||||
parseJsonSafe<any>(record?.description || '{}', {}).spec || {};
|
parseJsonSafe<any>(record?.description || '{}', {}).spec || {};
|
||||||
const resources = formatResources({ spec: description }, record);
|
const resources = formatResources({ spec: description }, record);
|
||||||
const accelerator = record.spec?.resources?.accelerator;
|
const accelerator = record.spec?.resources?.accelerator;
|
||||||
|
const sliceMemoryPercentage = getSliceMemoryPercentage(record);
|
||||||
|
const isSliced = description.acceleratable && sliceMemoryPercentage > 0;
|
||||||
const title =
|
const title =
|
||||||
options.title ??
|
options.title ??
|
||||||
(description.acceleratable
|
(description.acceleratable
|
||||||
? `${description.product} x ${accelerator}`
|
? isSliced
|
||||||
|
? `${description.product} (${sliceMemoryPercentage}%)`
|
||||||
|
: `${description.product} x ${accelerator}`
|
||||||
: 'CPU Only');
|
: 'CPU Only');
|
||||||
|
|
||||||
const volume = (record.spec as any)?.volume;
|
const volume = (record.spec as any)?.volume;
|
||||||
@@ -131,10 +167,16 @@ export const renderInstanceType = (
|
|||||||
icon: 'icon-gpu',
|
icon: 'icon-gpu',
|
||||||
name: 'GPU',
|
name: 'GPU',
|
||||||
rows: [
|
rows: [
|
||||||
[
|
// Sliced instances show the ratio instead of a card count (always 1).
|
||||||
intl.formatMessage({ id: 'gpuservice.table.count' }),
|
isSliced
|
||||||
accelerator ? `${accelerator}` : undefined
|
? [
|
||||||
],
|
intl.formatMessage({ id: 'gpuservice.instance.sliced' }),
|
||||||
|
`${sliceMemoryPercentage}%`
|
||||||
|
]
|
||||||
|
: [
|
||||||
|
intl.formatMessage({ id: 'gpuservice.table.count' }),
|
||||||
|
accelerator ? `${accelerator}` : undefined
|
||||||
|
],
|
||||||
[
|
[
|
||||||
intl.formatMessage({ id: 'gpuservice.instance.section.type' }),
|
intl.formatMessage({ id: 'gpuservice.instance.section.type' }),
|
||||||
description.product
|
description.product
|
||||||
|
|||||||
Reference in New Issue
Block a user