11from __future__ import annotations
22
3- from collections import defaultdict
3+ import json
44from decimal import Decimal
55
66import pytest
77
88from 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)
1714from ai .backend .common .exception import BackendAISchemaValidationFailed
1815from ai .backend .common .identifier .resource_slot import ResourceSlotName
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
5555def 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
99120class 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