Skip to content

Commit d2293c9

Browse files
fregataaclaude
andcommitted
refactor(BA-7353): describe a held device once, in both units
Review follow-up. `attached_devices` and the occupancy described the same devices from two sides — what is attached, and how much of it is held — and both were keyed by device name and then by device. A unit metered along two axes, as a `cuda` device is with `cuda.device` and `cuda.shares`, appeared twice with its attributes duplicated. - Merge them into `occupied_devices`, keyed by `DeviceName` and then `DeviceId`, so each unit is described once with every slot it supplies under it. - Carry the device's own measure of that allocation as `processing_units` and `memory_size`, mirroring `AbstractComputeDevice`, rather than the free-form `data` mapping the plugins fill. The two accelerator plugins in reach — the enterprise CUDA one and the mock — write exactly `smp` and `mem` there, which are those two fields projected onto the kernel's share; the intrinsic cpu plugin writes `cores`, which is the kernel's total core count copied onto every device and which nothing reads. - Keep the allocation itself keyed by `ResourceSlotName`. It is what `slot_totals` sums into the kernel's occupancy, so a device-measured quantity cannot live in it: `smp` is not a slot, and a GPU's `mem` is not the host memory slot of the same name. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 5064142 commit d2293c9

2 files changed

Lines changed: 137 additions & 133 deletions

File tree

src/ai/backend/common/events/event_types/kernel/types.py

Lines changed: 33 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -57,70 +57,53 @@ def from_value(cls, value: str | None) -> Self | None:
5757
return None
5858

5959

60-
class SlotOccupancy(BackendAISchema):
61-
"""How much of one slot each individual device supplies."""
62-
63-
amounts: Mapping[DeviceId, Decimal]
64-
65-
66-
class DeviceOccupancy(BackendAISchema):
67-
"""The slots one device supplies.
60+
class OccupiedDevice(BackendAISchema):
61+
"""
62+
One device unit the kernel holds, and how much of it.
6863
69-
A device supplies more than one slot when it is metered along more than one axis —
70-
`cuda` supplies both `cuda.device` and `cuda.shares`.
64+
`allocated` is in the scheduler's units — the slots it accounts by, of which a unit
65+
supplies more than one when it is metered along more than one axis, as a `cuda`
66+
device does with `cuda.device` and `cuda.shares`. `processing_units` and
67+
`memory_size` are that same allocation in the device's own units, mirroring
68+
`AbstractComputeDevice`; only an accelerator reports them.
7169
"""
7270

73-
slots: Mapping[ResourceSlotName, SlotOccupancy]
71+
model_name: (
72+
str | None
73+
) # Kept for the GPU usage stats, which aggregate the device models a kernel ran on.
74+
allocated: Mapping[ResourceSlotName, Decimal]
75+
processing_units: int | None
76+
memory_size: int | None
7477

7578

76-
class KernelOccupancy(BackendAISchema):
79+
class OccupiedDevices(BackendAISchema):
7780
"""
78-
The resources the kernel occupies, attributed to the devices supplying them.
81+
The devices the kernel occupies.
7982
80-
`DeviceName` names a device (`cuda`) and `DeviceId` one of its units (`0`), so the
81-
two levels keyed by a device are not the same thing.
83+
Keyed by device name (`cuda`) and then by unit (`0`): `DeviceName` names a kind of
84+
device, `DeviceId` one of its units.
8285
"""
8386

84-
devices: Mapping[DeviceName, DeviceOccupancy]
87+
units: Mapping[DeviceName, Mapping[DeviceId, OccupiedDevice]]
8588

8689
@property
8790
def slot_totals(self) -> list[ResourceSlotEntry]:
8891
"""
89-
The per-device amounts summed per slot — what a caller records as occupancy.
92+
The per-unit allocations summed per slot — what a caller records as occupancy.
9093
91-
A slot supplied by no device is left out rather than reported as zero, which is
92-
the distinction a caller storing the result depends on.
93-
94-
Not a `computed_field`: it would be written into the payload beside the
95-
occupancy it is derived from, and a receiver recomputes it anyway.
94+
Not a `computed_field`: it would be written into the payload beside the allocations
95+
it is derived from, where nothing reads it — a receiver recomputes it — and it
96+
can disagree with the value next to it.
9697
"""
97-
totals: list[ResourceSlotEntry] = []
98-
for device in self.devices.values():
99-
for slot_name, slot in device.slots.items():
100-
if not slot.amounts:
101-
continue
102-
total = sum(slot.amounts.values(), Decimal(0))
103-
totals.append(ResourceSlotEntry(resource_type=slot_name, quantity=str(total)))
104-
return totals
105-
106-
107-
class DeviceCapacity(BackendAISchema):
108-
"""
109-
What a device reports about itself.
110-
111-
Both are defaulted, unlike every other field here: the compute plugin's own
112-
`ComputedDeviceCapacity` declares them `NotRequired`, so a device that measures
113-
neither reports neither.
114-
"""
115-
116-
mem: int | None = None
117-
proc: int | None = None
118-
119-
120-
class AttachedDevice(BackendAISchema):
121-
device_id: DeviceId
122-
model_name: str
123-
data: DeviceCapacity
98+
totals: dict[ResourceSlotName, Decimal] = {}
99+
for units in self.units.values():
100+
for device in units.values():
101+
for slot_name, amount in device.allocated.items():
102+
totals[slot_name] = totals.get(slot_name, Decimal(0)) + amount
103+
return [
104+
ResourceSlotEntry(resource_type=slot_name, quantity=str(total))
105+
for slot_name, total in totals.items()
106+
]
124107

125108

126109
class ServicePortInfo(BackendAISchema):
@@ -144,5 +127,4 @@ class KernelCreationInfo(BackendAISchema):
144127
repl_in_port: int
145128
repl_out_port: int
146129
service_ports: list[ServicePortInfo]
147-
attached_devices: Mapping[DeviceName, list[AttachedDevice]]
148-
occupancy: KernelOccupancy
130+
occupied_devices: OccupiedDevices
Lines changed: 104 additions & 82 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,15 @@
11
from __future__ import annotations
22

3-
from collections import defaultdict
3+
import json
44
from decimal import Decimal
55

66
import pytest
77

88
from ai.backend.common.events.event_types.kernel.types import (
9-
AttachedDevice,
10-
DeviceCapacity,
11-
DeviceOccupancy,
129
KernelCreationInfo,
13-
KernelOccupancy,
10+
OccupiedDevice,
11+
OccupiedDevices,
1412
ServicePortInfo,
15-
SlotOccupancy,
1613
)
1714
from ai.backend.common.exception import BackendAISchemaValidationFailed
1815
from ai.backend.common.identifier.resource_slot import ResourceSlotName
@@ -25,35 +22,53 @@
2522
)
2623

2724

28-
def _info_with(**overrides: object) -> KernelCreationInfo:
29-
fields: dict[str, object] = {
30-
"container_id": ContainerId("c0ffee"),
31-
"kernel_host": "127.0.0.1",
32-
"repl_in_port": 2000,
33-
"repl_out_port": 2001,
34-
"service_ports": [],
35-
"attached_devices": {},
36-
"occupancy": KernelOccupancy(devices={}),
37-
**overrides,
38-
}
39-
return KernelCreationInfo(**fields) # type: ignore[arg-type]
40-
41-
42-
def _occupancy(**per_slot: dict[str, Decimal]) -> KernelOccupancy:
43-
"""Build an occupancy, taking the device name from the slot name as the agent does."""
44-
by_device: defaultdict[DeviceName, dict[ResourceSlotName, SlotOccupancy]] = defaultdict(dict)
45-
for slot, amounts in per_slot.items():
46-
by_device[DeviceName(slot.partition(".")[0])][ResourceSlotName(slot)] = SlotOccupancy(
47-
amounts={DeviceId(device_id): amount for device_id, amount in amounts.items()}
48-
)
49-
return KernelOccupancy(
50-
devices={name: DeviceOccupancy(slots=slots) for name, slots in by_device.items()}
25+
def _device(
26+
allocated: dict[str, Decimal],
27+
*,
28+
model_name: str | None = None,
29+
processing_units: int | None = None,
30+
memory_size: int | None = None,
31+
) -> OccupiedDevice:
32+
return OccupiedDevice(
33+
model_name=model_name,
34+
allocated={ResourceSlotName(slot): amount for slot, amount in allocated.items()},
35+
processing_units=processing_units,
36+
memory_size=memory_size,
37+
)
38+
39+
40+
def _info_with(
41+
occupied_devices: OccupiedDevices,
42+
service_ports: list[ServicePortInfo] | None = None,
43+
) -> KernelCreationInfo:
44+
return KernelCreationInfo(
45+
container_id=ContainerId("c0ffee"),
46+
kernel_host="127.0.0.1",
47+
repl_in_port=2000,
48+
repl_out_port=2001,
49+
service_ports=service_ports if service_ports is not None else [],
50+
occupied_devices=occupied_devices,
5151
)
5252

5353

5454
@pytest.fixture
5555
def creation_info() -> KernelCreationInfo:
56+
"""A kernel holding one cpu core, 1 GiB, and half of one GPU."""
5657
return _info_with(
58+
OccupiedDevices(
59+
units={
60+
DeviceName("cpu"): {DeviceId("0"): _device({"cpu": Decimal("1")})},
61+
DeviceName("mem"): {DeviceId("root"): _device({"mem": Decimal("1073741824")})},
62+
DeviceName("cuda"): {
63+
DeviceId("0"): _device(
64+
{"cuda.device": Decimal("1"), "cuda.shares": Decimal("0.5")},
65+
model_name="A100",
66+
processing_units=54,
67+
memory_size=21474836480,
68+
)
69+
},
70+
}
71+
),
5772
service_ports=[
5873
ServicePortInfo(
5974
name="jupyter",
@@ -63,16 +78,6 @@ def creation_info() -> KernelCreationInfo:
6378
is_inference=False,
6479
)
6580
],
66-
attached_devices={
67-
DeviceName("cuda"): [
68-
AttachedDevice(
69-
device_id=DeviceId("0"),
70-
model_name="A100",
71-
data=DeviceCapacity(mem=1024, proc=8),
72-
)
73-
]
74-
},
75-
occupancy=_occupancy(cpu={"0": Decimal("2")}, mem={"root": Decimal("4294967296")}),
7681
)
7782

7883

@@ -84,44 +89,60 @@ def test_roundtrip_keeps_every_typed_leaf(self, creation_info: KernelCreationInf
8489

8590
assert restored == creation_info
8691
assert restored.service_ports[0].protocol is ServicePortProtocols.HTTP
87-
assert restored.attached_devices[DeviceName("cuda")][0].data.mem == 1024
88-
assert restored.occupancy.devices[DeviceName("mem")].slots[
89-
ResourceSlotName("mem")
90-
].amounts == {DeviceId("root"): Decimal("4294967296")}
92+
cuda = restored.occupied_devices.units[DeviceName("cuda")][DeviceId("0")]
93+
assert cuda.model_name == "A100"
94+
assert (cuda.processing_units, cuda.memory_size) == (54, 21474836480)
95+
assert cuda.allocated[ResourceSlotName("cuda.shares")] == Decimal("0.5")
96+
97+
def test_one_unit_is_described_once(self, creation_info: KernelCreationInfo) -> None:
98+
"""The unit metered along two axes appears once, with both amounts under it —
99+
which is what merging the attached devices into the occupancy buys."""
100+
cuda = creation_info.occupied_devices.units[DeviceName("cuda")]
101+
102+
assert list(cuda) == [DeviceId("0")]
103+
assert set(cuda[DeviceId("0")].allocated) == {"cuda.device", "cuda.shares"}
104+
105+
def test_an_intrinsic_device_reports_neither_unit(
106+
self, creation_info: KernelCreationInfo
107+
) -> None:
108+
"""Only an accelerator measures itself; the cpu and mem plugins report nothing."""
109+
cpu = creation_info.occupied_devices.units[DeviceName("cpu")][DeviceId("0")]
110+
111+
assert (cpu.model_name, cpu.processing_units, cpu.memory_size) == (None, None, None)
91112

92113
def test_derived_totals_are_not_written_to_the_wire(
93114
self, creation_info: KernelCreationInfo
94115
) -> None:
95-
"""The occupancy is the payload; the per-slot sum is derived and stays off it."""
116+
"""The allocations are the payload; the per-slot sum is derived and stays off it."""
96117
assert "slot_totals" not in creation_info.model_dump_json()
97118

98119

99120
class TestSlotTotals:
100121
"""`slot_totals` is what a caller records as the kernel's occupancy."""
101122

102-
def test_one_device_supplying_several_slots(self) -> None:
103-
"""`cuda` is metered along two axes at once, by the same two units."""
104-
occupancy = KernelOccupancy(
105-
devices={
106-
DeviceName("cuda"): DeviceOccupancy(
107-
slots={
108-
ResourceSlotName("cuda.shares"): SlotOccupancy(
109-
amounts={
110-
DeviceId("0"): Decimal("0.5"),
111-
DeviceId("1"): Decimal("0.25"),
112-
}
113-
),
114-
ResourceSlotName("cuda.device"): SlotOccupancy(
115-
amounts={DeviceId("0"): Decimal("1"), DeviceId("1"): Decimal("1")}
116-
),
117-
}
118-
)
123+
def test_amounts_are_summed_across_units(self) -> None:
124+
occupied = OccupiedDevices(
125+
units={
126+
DeviceName("cuda"): {
127+
DeviceId("0"): _device({"cuda.shares": Decimal("0.5")}),
128+
DeviceId("1"): _device({"cuda.shares": Decimal("0.25")}),
129+
}
119130
}
120131
)
121132

122-
totals = {e.resource_type: e.quantity for e in occupancy.slot_totals}
133+
totals = {e.resource_type: e.quantity for e in occupied.slot_totals}
134+
135+
assert totals == {"cuda.shares": "0.75"}
123136

124-
assert totals == {"cuda.shares": "0.75", "cuda.device": "2"}
137+
def test_a_unit_reports_each_of_its_slots(self, creation_info: KernelCreationInfo) -> None:
138+
totals = {e.resource_type: e.quantity for e in creation_info.occupied_devices.slot_totals}
139+
140+
assert totals == {
141+
"cpu": "1",
142+
"mem": "1073741824",
143+
"cuda.device": "1",
144+
"cuda.shares": "0.5",
145+
}
125146

126147
@pytest.mark.parametrize(
127148
("slot", "amount"),
@@ -133,34 +154,35 @@ def test_one_device_supplying_several_slots(self) -> None:
133154
ids=["exact_bytes", "off_by_one_byte", "fractional"],
134155
)
135156
def test_amounts_survive_the_wire_exactly(self, slot: str, amount: Decimal) -> None:
136-
info = _info_with(occupancy=_occupancy(**{slot: {"0": amount}}))
157+
info = _info_with(
158+
OccupiedDevices(units={DeviceName(slot): {DeviceId("0"): _device({slot: amount})}})
159+
)
137160

138161
restored = KernelCreationInfo.model_validate_json(info.model_dump_json())
139162

140-
assert restored.occupancy.slot_totals == [
163+
assert restored.occupied_devices.slot_totals == [
141164
ResourceSlotEntry(resource_type=ResourceSlotName(slot), quantity=str(amount))
142165
]
143166

144167
@pytest.mark.parametrize("amount", ["Infinity", "-Infinity", "NaN"], ids=str)
145168
def test_non_finite_amount_is_rejected(self, amount: str) -> None:
146-
"""A device supplies a finite share of what it has; an unbounded amount is a
169+
"""A unit supplies a finite share of what it has; an unbounded amount is a
147170
limit, which is not what this carries."""
148-
payload = (
149-
'{"devices":{"cpu":{"slots":{"cpu":{"amounts":{"0":"%s"}}}}}}' % amount # noqa: UP031
150-
)
171+
payload = json.dumps({
172+
"units": {
173+
"cpu": {
174+
"0": {
175+
"model_name": None,
176+
"allocated": {"cpu": amount},
177+
"processing_units": None,
178+
"memory_size": None,
179+
}
180+
}
181+
}
182+
})
151183

152184
with pytest.raises(BackendAISchemaValidationFailed):
153-
KernelOccupancy.model_validate_json(payload)
154-
155-
def test_slot_supplied_by_no_device_is_omitted(self) -> None:
156-
"""Omitted, not zero — the caller stores the result as occupancy, where a slot
157-
present at zero and a slot absent are not the same statement."""
158-
occupancy = KernelOccupancy(
159-
devices={
160-
DeviceName("cuda"): DeviceOccupancy(
161-
slots={ResourceSlotName("cuda.device"): SlotOccupancy(amounts={})}
162-
)
163-
}
164-
)
185+
OccupiedDevices.model_validate_json(payload)
165186

166-
assert occupancy.slot_totals == []
187+
def test_a_kernel_holding_nothing_totals_nothing(self) -> None:
188+
assert OccupiedDevices(units={}).slot_totals == []

0 commit comments

Comments
 (0)