Skip to content
Merged
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
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,20 @@ Only used with `compute.cpu-mode` is set to `custom`.
For more details please refer to the Nova [configuration reference](https://docs.openstack.org/nova/latest/admin/cpu-models.html)
for cpu models.

* `compute.cpu-pinning-profile` CPU topology profile for Nova pinning

When unset (default), the snap uses EPA-returned `allocated_cores` and
`shared_cpus` directly.

When set to JSON (as sent by the charm) like:

`{"dedicated_percentage": 40, "requested_cores_percentage": 90}`

the snap requests EPA `allocated_cores` sized by `requested_cores_percentage`
using the `allocate_cores_percent` socket action, then applies the
`dedicated_percentage` split strategy to produce Nova's
`cpu_dedicated_set`/`cpu_shared_set`.

* `compute.spice-proxy-address` (`localhost`) IP address for SPICE consoles

IP address to use for configuration of SPICE consoles in instances.
Expand Down
24 changes: 23 additions & 1 deletion openstack_hypervisor/cli/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@

from .schemas import (
ActionType,
AllocateCoresPercentRequest,
AllocateCoresPercentResponse,
AllocateCoresRequest,
AllocateCoresResponse,
AllocateHugepagesRequest,
Expand Down Expand Up @@ -57,6 +59,7 @@ def socket_path(snap: Snap) -> str:
def _communicate_with_socket(
request: Union[
AllocateCoresRequest,
AllocateCoresPercentRequest,
AllocateNumaCoresRequest,
AllocateHugepagesRequest,
GetMemoryInfoRequest,
Expand All @@ -83,7 +86,7 @@ def _communicate_with_socket(
try:
with pysocket.socket(pysocket.AF_UNIX, pysocket.SOCK_STREAM) as s:
s.connect(socket_path)
s.sendall(request.json().encode())
s.sendall(request.model_dump_json(by_alias=True, exclude_none=True).encode())
data = s.recv(4096)
response_dict = json.loads(data.decode())
if "error" in response_dict:
Expand Down Expand Up @@ -129,3 +132,22 @@ def get_cpu_pinning_from_socket(
except (SocketCommunicationError, EPAOrchestratorError) as e:
logging.error("Failed to get CPU pinning info from socket: {}".format(e))
raise


def get_cpu_pinning_percent_from_socket(
service_name: str,
socket_path: str,
requested_cores_percentage: int,
) -> str:
"""Get allocated cores from EPA based on requested percentage.

Returns EPA `allocated_cores` only; callers can derive any shared/dedicated
Nova sets they need.
"""
request = AllocateCoresPercentRequest(
service_name=service_name,
action=ActionType.ALLOCATE_CORES_PERCENT,
percent=requested_cores_percentage,
)
response = _communicate_with_socket(request, AllocateCoresPercentResponse, socket_path)
return response.allocated_cores
39 changes: 35 additions & 4 deletions openstack_hypervisor/cli/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ class ActionType(str, Enum):
"""Enum for different action types."""

ALLOCATE_CORES = "allocate_cores"
ALLOCATE_CORES_PERCENT = "allocate_cores_percent"
LIST_ALLOCATIONS = "list_allocations"
ALLOCATE_NUMA_CORES = "allocate_numa_cores"
GET_MEMORY_INFO = "get_memory_info"
Expand All @@ -30,8 +31,18 @@ class AllocateCoresRequest(BaseModel):
default=0,
description=("Number of dedicated cores requested. 0 keeps default policy."),
)
numa_node: Optional[int] = Field(
default=None, ge=0, description="NUMA node (must be omitted for allocate_cores)"


class AllocateCoresPercentRequest(BaseModel):
"""Request model for allocating a percentage of isolated cores."""

version: Literal["1.0"] = Field(default=API_VERSION)
action: Literal[ActionType.ALLOCATE_CORES_PERCENT]
service_name: str = Field(description="Name of the requesting service")
percent: int = Field(
ge=-1,
le=100,
description="Percentage of isolated cores to allocate (0-100). -1 or 0 to deallocate.",
)


Expand All @@ -40,7 +51,10 @@ class ListAllocationsRequest(BaseModel):

version: Literal["1.0"] = Field(default=API_VERSION)
action: Literal[ActionType.LIST_ALLOCATIONS]
service_name: str = Field(description="Name of the requesting service")
service_name: Optional[str] = Field(
default=None,
description="Name of the requesting service (optional)",
)


class AllocateNumaCoresRequest(BaseModel):
Expand All @@ -64,7 +78,10 @@ class GetMemoryInfoRequest(BaseModel):

version: Literal["1.0"] = Field(default=API_VERSION)
action: Literal[ActionType.GET_MEMORY_INFO]
service_name: str = Field(description="Name of the requesting service")
service_name: Optional[str] = Field(
default=None,
description="Name of the requesting service (optional)",
)


class AllocateHugepagesRequest(BaseModel):
Expand Down Expand Up @@ -97,6 +114,7 @@ def validate_hugepages_requested(cls, v: int) -> int:
EpaRequest = Annotated[
Union[
AllocateCoresRequest,
AllocateCoresPercentRequest,
AllocateNumaCoresRequest,
ListAllocationsRequest,
GetMemoryInfoRequest,
Expand All @@ -121,6 +139,19 @@ class AllocateCoresResponse(BaseModel):
)


class AllocateCoresPercentResponse(BaseModel):
"""Pydantic model for allocate cores percent response."""

version: Literal["1.0"] = Field(default=API_VERSION)
service_name: str = Field(description="Name of the service that was allocated cores")
cores_allocated_count: int = Field(description="Number of cores that were actually allocated")
allocated_cores: str = Field(description="Comma-separated list of allocated CPU ranges")
total_available_cpus: int = Field(description="Total number of CPUs available in the system")
remaining_available_cpus: int = Field(
description="Number of CPUs still available for allocation"
)


class AllocateNumaCoresResponse(BaseModel):
"""Pydantic model for NUMA allocate cores response."""

Expand Down
103 changes: 85 additions & 18 deletions openstack_hypervisor/hooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
import ipaddress
import json
import logging
import math
import os
import platform
import re
Expand Down Expand Up @@ -54,6 +55,7 @@
EPAOrchestratorError,
SocketCommunicationError,
get_cpu_pinning_from_socket,
get_cpu_pinning_percent_from_socket,
socket_path,
)
from openstack_hypervisor.log import setup_logging
Expand Down Expand Up @@ -354,6 +356,7 @@ def _get_local_ip_by_default_route() -> str:
"compute.key": UNSET,
"compute.migration-address": UNSET,
"compute.resume-on-boot": True,
"compute.cpu-pinning-profile": UNSET,
"compute.flavors": UNSET,
"compute.pci-device-specs": [],
"compute.pci-excluded-devices": [],
Expand Down Expand Up @@ -493,6 +496,52 @@ def _context_compat(context: Dict[str, Any]) -> Dict[str, Any]:
return clean_context


def _expand_cpu_ranges(cpu_ranges: str) -> List[int]:
"""Expand comma-separated CPU ranges into an ordered list of CPU ids."""
cpus: List[int] = []
for token in cpu_ranges.split(","):
token = token.strip()
if not token:
continue
if "-" in token:
start, end = token.split("-", 1)
cpus.extend(list(range(int(start), int(end) + 1)))
else:
cpus.append(int(token))
return cpus


def _compress_cpu_ranges(cpu_list: List[int]) -> str:
"""Compress an ordered CPU list into Nova-compatible range notation."""
if not cpu_list:
return ""

ranges: List[str] = []
start = prev = cpu_list[0]
for cpu in cpu_list[1:]:
if cpu == prev + 1:
prev = cpu
continue
ranges.append(f"{start}-{prev}" if start != prev else str(start))
start = prev = cpu
ranges.append(f"{start}-{prev}" if start != prev else str(start))
return ",".join(ranges)


def _split_dedicated_cores_by_profile(
dedicated_cores: str, dedicated_percentage: int
) -> tuple[str, str]:
"""Split dedicated cores into (shared_set, dedicated_set) by percentage."""
cores = _expand_cpu_ranges(dedicated_cores)
if not cores:
return "", ""

dedicated_count = math.ceil(len(cores) * dedicated_percentage / 100)
Comment thread
ahmad-can marked this conversation as resolved.
profile_dedicated = cores[:dedicated_count]
profile_shared = cores[dedicated_count:]
return _compress_cpu_ranges(profile_shared), _compress_cpu_ranges(profile_dedicated)
Comment thread
ahmad-can marked this conversation as resolved.


TEMPLATES = {
Path("etc/nova/nova.conf"): {
"template": "nova.conf.j2",
Expand Down Expand Up @@ -3021,17 +3070,6 @@ def configure(snap: Snap) -> None:


def _get_configure_context(snap: Snap) -> dict:
try:
cpu_shared_set, allocated_cores = get_cpu_pinning_from_socket(
service_name=snap.name, socket_path=socket_path(snap), cores_requested=0
)
except (SocketCommunicationError, EPAOrchestratorError) as e:
if "No Isolated CPUs configured" in str(e):
logging.info("No Isolated CPUs configured, continuing without CPU pinning.")
cpu_shared_set, allocated_cores = "", ""
else:
logging.warning(f"Failed to get CPU pinning info from EPA orchestrator: {e}")
cpu_shared_set, allocated_cores = "", ""
context = snap.config.get_options(
"compute",
"network",
Expand All @@ -3047,13 +3085,6 @@ def _get_configure_context(snap: Snap) -> dict:
"sev",
"internal",
).as_dict()
context["compute"]["allocated_cores"] = allocated_cores
context["compute"]["cpu_shared_set"] = cpu_shared_set

context["compute"]["multipath_enabled"] = (
context["compute"].get("multipath_forced", False) or _is_multipathd_available()
)

context.update(
{
"snap_common": str(snap.paths.common),
Expand All @@ -3062,6 +3093,42 @@ def _get_configure_context(snap: Snap) -> dict:
}
)
context = _context_compat(context)
context.setdefault("compute", {})
context.setdefault("network", {})
context.setdefault("identity", {})
context["compute"]["multipath_enabled"] = (
context["compute"].get("multipath_forced", False) or _is_multipathd_available()
)

cpu_pinning_profile = context["compute"].get("cpu_pinning_profile")
try:
if cpu_pinning_profile:
dedicated_percentage = int(cpu_pinning_profile["dedicated_percentage"])
requested_cores_percentage = int(cpu_pinning_profile["requested_cores_percentage"])
allocated_cores = get_cpu_pinning_percent_from_socket(
service_name=snap.name,
socket_path=socket_path(snap),
requested_cores_percentage=requested_cores_percentage,
)
cpu_shared_set, allocated_cores = _split_dedicated_cores_by_profile(
allocated_cores, dedicated_percentage
)
else:
Comment thread
ahmad-can marked this conversation as resolved.
Comment thread
ahmad-can marked this conversation as resolved.
cpu_shared_set, allocated_cores = get_cpu_pinning_from_socket(
service_name=snap.name,
socket_path=socket_path(snap),
cores_requested=0,
)
except (SocketCommunicationError, EPAOrchestratorError) as e:
if "No Isolated CPUs configured" in str(e):
logging.info("No Isolated CPUs configured, continuing without CPU pinning.")
else:
logging.warning(f"Failed to get CPU pinning info from EPA orchestrator: {e}")
cpu_shared_set, allocated_cores = "", ""

context["compute"]["allocated_cores"] = allocated_cores
context["compute"]["cpu_shared_set"] = cpu_shared_set

logging.info(context)

if not context.get("identity"):
Expand Down
6 changes: 5 additions & 1 deletion templates/nova.conf.j2
Original file line number Diff line number Diff line change
Expand Up @@ -74,11 +74,15 @@ swtpm_group = snap_daemon
# are placed on the shared set, but this can be customized in Nova. For more details on
# emulator thread pinning policies and their impact, see:
# https://docs.openstack.org/nova/latest/admin/cpu-topologies.html#customizing-instance-emulator-thread-pinning-policy
{% if compute.allocated_cores and compute.cpu_shared_set -%}
{% if compute.allocated_cores or compute.cpu_shared_set -%}
[compute]
{% if compute.allocated_cores -%}
cpu_dedicated_set = {{ compute.allocated_cores }}
{% endif -%}
{% if compute.cpu_shared_set -%}
cpu_shared_set = {{ compute.cpu_shared_set }}
{% endif %}
{% endif -%}

{% if compute.rbd_secret_uuid -%}
rbd_user = {{ compute.rbd_user }}
Expand Down
Loading
Loading