A distributed, adaptive resource orchestrator for dynamic workload scheduling across embedded Linux edge nodes, written in modern C++20.
Author: Dimitris Kafetzis
Version: 1.0 (Implementation Complete)
Target Platform: Raspberry Pi 4/5 — Embedded Linux (64-bit ARM)
Language Standard: C++20 (GCC 12+ / Clang 15+)
License: MIT
- Motivation & Research Context
- System Overview
- Architecture
- Module Specifications
- C++20 Feature Usage
- Interface Definitions
- Scheduling Policies
- Synthetic Workload Model
- Inter-Node Protocol
- Build System & Toolchain
- Testing Strategy
- Project Roadmap
- References
Deploying AI inference workloads (e.g., deep neural networks, large language models) on resource-constrained edge devices is fundamentally limited by per-device compute capacity, memory, and thermal constraints. When a cluster of edge nodes is available, intelligent workload distribution can dramatically improve throughput, latency, and resource utilization — but this requires an orchestrator that adapts in real time to fluctuating resource availability.
This project is a practical realization of research on dynamic partitioning and resource orchestration for AI model deployment in resource-constrained wireless edge networks, conducted at the Athens University of Economics and Business. Key publications that inform this design include:
- DNN Inference Partitioning — Dynamic partitioning of deep neural network inference across heterogeneous edge devices with per-layer cost modeling.
- LLM Partitioning with Dynamic Memory Management — Extending partitioning to transformer architectures with KV-cache-aware memory budgeting.
- Multi-Layer Transformer Partitioning with Speculative Execution — Cross-layer similarity discovery and layer-level speculative execution for reduced inference latency.
The synthetic workload model in this project mirrors the computational characteristics of transformer layers (configurable compute cost, memory footprint, inter-layer data dependencies), and the optimization-based scheduling policy implements a lightweight version of the partitioning formulations from the above research.
- Demonstrate modern C++20 mastery in a systems-level, distributed context.
- Bridge academic research and production engineering by implementing research-inspired scheduling algorithms in a real, deployable system.
- Provide a clean, extensible architecture that can serve as a reference for embedded distributed systems design.
- Run on real hardware (Raspberry Pi cluster) while remaining fully testable on a single desktop machine via simulation.
EdgeOrchestrator is a distributed daemon that runs on each node in a cluster of embedded Linux devices. Each daemon instance:
- Monitors local hardware resources (CPU, memory, thermal, network) continuously.
- Discovers peer nodes via UDP broadcast and maintains a cluster membership view.
- Advertises its resource availability to all peers periodically.
- Receives workload submission requests — directed acyclic graphs (DAGs) of computational tasks.
- Schedules tasks across the cluster using a pluggable policy, deciding what runs locally vs. what gets offloaded to peers.
- Executes locally scheduled tasks in a managed thread pool with memory and CPU budgets.
- Offloads remotely scheduled tasks to peer nodes via Protobuf-serialized RPC over TCP.
- Collects intermediate results from remote executions and assembles final outputs.
- Logs every decision, resource snapshot, and execution event as structured NDJSON for offline analysis.
┌─────────────────────────────────────────────────────────────┐
│ Physical Deployment │
│ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ RPi #1 │◄──►│ RPi #2 │◄──►│ RPi #3 │ │
│ │ (Node A) │ │ (Node B) │ │ (Node C) │ │
│ └──────────┘ └──────────┘ └──────────┘ │
│ ▲ ▲ ▲ │
│ └───────────────┼───────────────┘ │
│ LAN / Wi-Fi Mesh │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ Simulated Deployment │
│ │
│ ┌──────────────────────────────────┐ │
│ │ Single Desktop / CI │ │
│ │ │ │
│ │ Node A ◄──► Node B ◄──► Node C │ │
│ │ :5001 :5002 :5003 │ │
│ │ (localhost, different ports) │ │
│ └──────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
graph TB
subgraph "EdgeOrchestrator Daemon (per node)"
APP[Application<br/>main.cpp, config, signals]
RM[Resource Monitor<br/>CPU, Memory, Thermal]
WL[Workload Model<br/>Task DAGs, Generators]
SCH[Scheduler<br/>Pluggable Policies]
EXE[Executor<br/>Thread Pool, Memory Pool]
NET[Network Layer<br/>Discovery, Transport, Cluster View]
TEL[Telemetry<br/>Metrics, JSON Sink]
CORE[Core<br/>Types, Result, Logger]
end
APP --> RM
APP --> SCH
APP --> NET
APP --> TEL
RM -->|Resource Snapshots| SCH
WL -->|Task DAGs| SCH
NET -->|Peer Resources| SCH
SCH -->|Local Tasks| EXE
SCH -->|Remote Tasks| NET
NET -->|Remote Results| SCH
EXE -->|Execution Events| TEL
SCH -->|Scheduling Decisions| TEL
RM -->|Resource Events| TEL
RM --> CORE
WL --> CORE
SCH --> CORE
EXE --> CORE
NET --> CORE
TEL --> CORE
sequenceDiagram
participant Client as Workload Injector
participant App as Application
participant RM as Resource Monitor
participant Sched as Scheduler
participant Exec as Executor
participant Net as Network
participant Peer as Peer Node
participant Tel as Telemetry
Client->>App: Submit workload DAG
App->>Sched: schedule(dag)
Sched->>RM: get_snapshot()
RM-->>Sched: ResourceSnapshot
Sched->>Net: get_cluster_view()
Net-->>Sched: ClusterView (peer resources)
Sched->>Sched: Apply scheduling policy
Sched->>Tel: log(SchedulingDecision)
par Local Execution
Sched->>Exec: submit(local_tasks)
Exec->>Exec: Run in thread pool
Exec->>Tel: log(TaskCompleted)
and Remote Offloading
Sched->>Net: offload(remote_tasks, peer_id)
Net->>Peer: Protobuf RPC
Peer-->>Net: TaskResult
Net->>Sched: deliver(result)
end
Sched->>App: WorkloadResult
App->>Client: Response
- Separation of Concerns: Each module has a single responsibility and communicates through well-defined interfaces.
- Hybrid Polymorphism: Concepts (static polymorphism) for performance-critical hot paths (scheduling, execution). Virtual interfaces for configuration-time flexibility (config loaders, log sinks).
- Zero-Cost Where It Matters: No dynamic allocation in the execution hot path. Arena allocator with a hard budget for task working memory; lock-free task hand-off.
- Testability First: Every module depends on abstractions, not concretions. Mock implementations for resource monitor, network, and executor enable comprehensive unit testing.
- Graceful Degradation: If a peer node becomes unreachable, tasks are rescheduled locally. If local resources are exhausted, tasks are queued rather than dropped.
Purpose: Shared type definitions, error handling, and utilities used by all modules.
Key Components:
| File | Description |
|---|---|
types.hpp |
Fundamental types: NodeId, TaskId, Timestamp, ResourceSnapshot, Duration |
result.hpp |
Result<T, E> — a monadic error type wrapping std::expected (C++23) or a custom implementation |
logger.hpp |
ILogSink virtual interface for pluggable log destinations |
config.hpp |
Config struct with TOML/YAML deserialization for daemon configuration |
concepts.hpp |
Shared C++20 concept definitions used across modules |
ResourceSnapshot Structure:
struct ResourceSnapshot {
NodeId node_id;
Timestamp timestamp;
float cpu_usage_percent; // Aggregate CPU usage [0.0, 100.0]
std::array<float,4> per_core_cpu_percent; // Per-core (RPi4 has 4 cores)
uint64_t memory_available_bytes;
uint64_t memory_total_bytes;
float cpu_temperature_celsius;
float gpu_temperature_celsius;
uint64_t network_rx_bytes_sec; // Receive throughput
uint64_t network_tx_bytes_sec; // Transmit throughput
bool is_throttled; // Thermal throttling active
[[nodiscard]] float memory_usage_percent() const noexcept;
[[nodiscard]] float cpu_headroom_percent() const noexcept;
};Result<T, E> Pattern:
template <typename T, typename E = std::string>
using Result = std::expected<T, E>;
// Usage:
Result<ResourceSnapshot> snapshot = monitor.read();
if (snapshot) {
process(*snapshot);
} else {
logger.error("Monitor failed: {}", snapshot.error());
}Purpose: Continuously reads system resource metrics from Linux pseudo-filesystems and exposes them to the scheduler.
Interface (Concept-constrained for hot path):
template <typename T>
concept ResourceMonitorLike = requires(T monitor) {
{ monitor.read() } -> std::same_as<Result<ResourceSnapshot>>;
{ monitor.cpu_usage() } -> std::convertible_to<float>;
{ monitor.memory_available() } -> std::convertible_to<uint64_t>;
{ monitor.is_throttled() } -> std::convertible_to<bool>;
};Implementation Details:
| Metric | Linux Source | Read Method |
|---|---|---|
| CPU Usage | /proc/stat |
Delta of user+nice+system vs idle over sampling interval |
| Memory | /proc/meminfo |
MemAvailable field |
| Temperature | /sys/class/thermal/thermal_zone0/temp |
Direct read, divide by 1000 |
| Throttling | /sys/devices/platform/soc/soc:firmware/get_throttled |
Bitfield (RPi-specific) |
| Network I/O | /proc/net/dev |
Delta of rx_bytes / tx_bytes per interface |
Sampling Strategy:
- A dedicated
std::jthreadsamples at a configurable interval (default: 500ms). - The latest snapshot is stored in a
std::atomic<std::shared_ptr<ResourceSnapshot>>for lock-free reads. - An observer pattern allows modules to register callbacks for threshold-crossing events (e.g., memory > 80%).
Mock Implementation:
MockResourceMonitor allows injection of predetermined resource sequences for testing. It satisfies the same ResourceMonitorLike concept.
Purpose: Represents computational tasks and their dependency relationships as directed acyclic graphs (DAGs).
Key Types:
struct TaskProfile {
std::chrono::microseconds compute_cost; // Expected execution time
uint64_t memory_bytes; // Working memory requirement
uint64_t input_bytes; // Input data size (transfer cost)
uint64_t output_bytes; // Output data size (transfer cost)
};
struct Task {
TaskId id;
std::string name;
TaskProfile profile;
std::vector<TaskId> dependencies; // Must complete before this task
TaskState state = TaskState::Pending;
};
enum class TaskState : uint8_t {
Pending, // Waiting for dependencies
Ready, // All dependencies met, awaiting scheduling
Scheduled, // Assigned to a node
Running, // Currently executing
Completed, // Finished successfully
Failed // Execution failed
};DAG Representation:
class WorkloadDAG {
public:
// Construction
TaskId add_task(Task task);
void add_dependency(TaskId from, TaskId to); // 'from' must complete before 'to'
// Queries
[[nodiscard]] std::vector<TaskId> topological_order() const;
[[nodiscard]] std::vector<TaskId> ready_tasks() const; // No unmet dependencies
[[nodiscard]] std::vector<TaskId> dependents(const TaskId&) const;
[[nodiscard]] std::vector<TaskId> dependencies(const TaskId&) const;
[[nodiscard]] bool is_valid() const; // Acyclicity check
// State updates
void mark_completed(TaskId id);
void mark_failed(TaskId id);
// Metrics
[[nodiscard]] std::chrono::microseconds critical_path_cost() const;
[[nodiscard]] uint64_t peak_memory_estimate() const;
private:
std::unordered_map<TaskId, Task> tasks_;
std::unordered_map<TaskId, std::vector<TaskId>> adj_list_; // forward edges
std::unordered_map<TaskId, std::vector<TaskId>> reverse_adj_; // backward edges
};Synthetic Workload Generator:
class WorkloadGenerator {
public:
// Topology presets (model common AI workload patterns)
static WorkloadDAG linear_chain(size_t num_tasks, TaskProfile base_profile);
static WorkloadDAG fan_out_fan_in(size_t width, TaskProfile base_profile);
static WorkloadDAG diamond(size_t depth, size_t width, TaskProfile base_profile);
static WorkloadDAG transformer_layers(size_t num_layers,
uint64_t hidden_dim,
uint64_t kv_cache_bytes);
// Randomized generation
static WorkloadDAG random_dag(size_t num_tasks,
float edge_probability,
TaskProfile min_profile,
TaskProfile max_profile,
std::mt19937& rng);
};Purpose: The decision-making core. Receives resource snapshots and workload DAGs, produces a scheduling plan mapping tasks to nodes.
Scheduling Plan Output:
struct SchedulingDecision {
TaskId task_id;
NodeId assigned_node; // Which node executes this task
enum class Reason : uint8_t {
LocalCapacity, // Sufficient local resources
LeastLoaded, // Greedy: assigned to least-loaded node
ThresholdOffload, // Local load exceeded threshold
OptimizationResult, // Result of optimization solver
Fallback // All peers unavailable, run locally
} reason;
};
struct SchedulingPlan {
std::vector<SchedulingDecision> decisions;
std::chrono::microseconds estimated_makespan;
Timestamp computed_at;
};Policy Interface (Concept for hot path):
template <typename T>
concept SchedulingPolicyLike = requires(
T policy,
const WorkloadDAG& dag,
const ResourceSnapshot& local,
const ClusterView& cluster
) {
{ policy.schedule(dag, local, cluster) } -> std::same_as<SchedulingPlan>;
{ T::name() } -> std::convertible_to<std::string_view>;
};Three policies are provided (see Section 7 for algorithmic details):
GreedyPolicy— Assigns each ready task to the node with the most available resources.ThresholdPolicy— Executes locally until a load threshold is breached, then offloads.OptimizerPolicy— Minimizes estimated makespan subject to per-node resource constraints.
Policy Selection at Runtime:
The policy is chosen from configuration (scheduler.policy in TOML), so the variation axis is runtime: policies implement the ISchedulingPolicy virtual interface and the orchestrator holds a std::unique_ptr<ISchedulingPolicy> created by create_policy(). One virtual call per scheduling round is noise next to the O(T·N) work inside the policy, so buying that flexibility with a vtable is the right trade.
std::unique_ptr<ISchedulingPolicy> Orchestrator::create_policy() const {
if (config_.scheduler.policy == "threshold") return std::make_unique<ThresholdPolicy>(...);
if (config_.scheduler.policy == "optimizer") return std::make_unique<OptimizerPolicy>(...);
return std::make_unique<GreedyPolicy>(); // default
}The SchedulingPolicyLike concept covers the compile-time axis instead: it documents the policy contract and is enforced with static_asserts in the test suite, so a policy that drifts from the interface fails to compile rather than failing at runtime. (Contrast the monitor, where the variation is between test and production builds — that one is a template parameter, Orchestrator<MonitorT>, because the choice is known at compile time.)
Purpose: Manages local task execution with resource budgets, using a std::jthread-based thread pool.
Thread Pool Design:
class ThreadPool {
public:
explicit ThreadPool(size_t num_threads); // Default: hardware_concurrency()
~ThreadPool(); // jthreads auto-join via stop tokens
// Submit a task for execution
template <std::invocable F>
std::future<std::invoke_result_t<F>> submit(F&& func);
// Submit with cancellation support
template <std::invocable<std::stop_token> F>
std::future<std::invoke_result_t<F, std::stop_token>> submit_cancellable(F&& func);
// Metrics
[[nodiscard]] size_t active_count() const noexcept;
[[nodiscard]] size_t queued_count() const noexcept;
private:
// Vyukov bounded MPMC ring: producers/consumers claim cells with one
// CAS each; a counting semaphore lets idle workers sleep (no spinning
// on battery/thermal-constrained hardware). Bounded on purpose:
// try_push rejection is backpressure, an unbounded queue is a slow OOM.
MpmcQueue<std::function<void(std::stop_token)>> task_queue_{QUEUE_CAPACITY};
std::counting_semaphore<> tasks_available_{0};
std::atomic<size_t> active_tasks_{0};
std::vector<std::jthread> workers_; // declared last: destroyed (joined) first
};Measured on a 16-core x86 host (bench_queue), the ring beats the previous mutex+std::queue hand-off by ~1.3× at 2×2 and 4×4 producers/consumers, loses slightly at 1×1 (an uncontended mutex is cheap), and ties at 8×8 — lock-free is a contention tool, not a universal win. Numbers on Pi-class hardware to follow with the benchmark suite.
Memory Pool (Arena Allocator):
For the execution hot path, dynamic allocation is avoided. A per-task arena allocator provides fast, predictable allocation:
class MemoryPool {
public:
explicit MemoryPool(size_t capacity_bytes);
[[nodiscard]] void* allocate(size_t size, size_t alignment = alignof(std::max_align_t));
void reset() noexcept; // Deallocate all at once (arena-style)
[[nodiscard]] size_t used() const noexcept;
[[nodiscard]] size_t capacity() const noexcept;
[[nodiscard]] bool can_allocate(size_t size) const noexcept;
private:
std::unique_ptr<std::byte[]> buffer_;
size_t capacity_;
std::atomic<size_t> offset_{0};
};Task Runner:
Wraps actual task execution with budget enforcement:
class TaskRunner {
public:
struct ExecutionResult {
TaskId task_id;
TaskState final_state;
std::chrono::microseconds actual_duration;
uint64_t peak_memory_bytes;
std::optional<std::string> error_message;
};
ExecutionResult execute(const Task& task,
MemoryPool& pool,
std::stop_token stop);
};Purpose: Inter-node communication — peer discovery, resource advertisement, task offloading, and result collection.
Sub-Components:
- Mechanism: UDP broadcast on the node port (default: 5201).
- Heartbeat: Each node broadcasts a fixed 72-byte packed advertisement every 2 seconds.
- Timeout: Peers not heard from in 6 seconds (3 missed heartbeats) are evicted.
The discovery path deliberately does not use Protobuf: the advert is a
fixed-size packed struct (PeerDiscovery::AdvertPacket) — magic "EORC",
protocol version, node id, TCP port, CPU/memory/thermal fields, flags.
It is high-frequency, loss-tolerant, and never evolves per-field, so a
parse-free fixed layout is cheaper and simpler. All multi-byte fields are
big-endian on the wire (protocol v2; v1 sent host order and is rejected
by the version check), floats travel as IEEE-754 bit patterns, and the
field offsets are pinned by a layout test because they are the wire
contract. The receiver records the sender's IP and advertised TCP port,
which is how the offload client later resolves node_id → endpoint.
- Protocol: TCP carrying Protobuf-serialized
Envelopemessages. - Framing: 4-byte big-endian length prefix + serialized Envelope, 16 MB cap checked before allocation (a hostile length prefix cannot exhaust memory).
- Server:
AsyncTransport— C++20 coroutines over a single-threadedepollreactor. Each accepted connection runs as a coroutine (DetachedTask) that parks on fd readiness (co_await reactor.wait_readable(fd, deadline)); handlers hop to a small worker pool so a slow workload submission never blocks the reactor or other peers' offload requests. - Client: the synchronous,
poll()-basedTcpTransport. Offload calls already run on executor worker threads that own the operation end-to-end, so coroutines would buy nothing there — async where concurrency pays (server fan-in), sync where a thread already owns the wait.
// Server side (per accepted connection, on the reactor):
DetachedTask AsyncTransport::handle_connection(int fd) {
// read [4B length][Envelope], bounds-checked
if (co_await read_exact(fd, header, 4)) { ... }
co_await PoolHop{handler_pool_}; // handler off the reactor thread
auto response = handler_(payload);
co_await write_exact(fd, response...); // back on the reactor for I/O
}Serialization stays quarantined in OffloadCodec (one .cpp): the transport
layers traffic in std::vector<uint8_t>, and no generated Protobuf header
leaks into module interfaces.
Note:
Async<T>/DetachedTask(core/async.hpp) are coroutine return types, distinct from the workloadTaskstruct.
Aggregates peer information into a consistent view for the scheduler:
class ClusterView {
public:
void update_peer(NodeId id, ResourceSnapshot snapshot);
void remove_peer(NodeId id);
[[nodiscard]] std::vector<NodeId> available_peers() const;
[[nodiscard]] std::optional<ResourceSnapshot> peer_resources(NodeId id) const;
[[nodiscard]] NodeId least_loaded_peer() const;
[[nodiscard]] size_t cluster_size() const noexcept;
private:
mutable std::shared_mutex mutex_;
std::unordered_map<NodeId, PeerInfo> peers_;
};syntax = "proto3";
package edge_orchestrator;
// Resource advertisement (UDP broadcast)
message NodeAdvertisement {
string node_id = 1;
string address = 2;
uint32 tcp_port = 3;
ResourceSummary resources = 4;
uint64 timestamp_ms = 5;
}
message ResourceSummary {
float cpu_usage_percent = 1;
uint64 memory_available_bytes = 2;
uint64 memory_total_bytes = 3;
float temperature_celsius = 4;
bool is_throttled = 5;
}
// Task offloading (TCP)
message TaskProfileMsg {
uint64 compute_cost_us = 1;
uint64 memory_bytes = 2;
uint64 input_bytes = 3;
uint64 output_bytes = 4;
}
message OffloadRequest {
string request_id = 1; // reserved: correlation is per-connection today
string task_id = 2;
string task_name = 3;
TaskProfileMsg profile = 4;
bytes input_data = 5;
}
message OffloadResponse {
string request_id = 1;
string task_id = 2;
bool success = 3;
bytes output_data = 4;
uint64 actual_duration_us = 5;
uint64 peak_memory_bytes = 6;
string error_message = 7;
}
// Workload submission (TCP) — how external clients hand a DAG to a node.
// tools/workload_injector.py speaks this from Python via the same .proto.
message TaskSpec {
string task_id = 1;
string name = 2;
TaskProfileMsg profile = 3;
repeated string dependencies = 4;
}
message WorkloadSubmission {
string workload_id = 1;
repeated TaskSpec tasks = 2;
}
message WorkloadResult {
string workload_id = 1;
bool accepted = 2;
string error_message = 3;
uint64 local_tasks = 4;
uint64 offloaded_tasks = 5;
uint64 completed_tasks = 6;
uint64 failed_tasks = 7;
uint64 offload_fallbacks = 8;
uint64 makespan_estimate_us = 9;
uint64 total_duration_us = 10;
}
// Wrapper for multiplexing message types; the server dispatches on the
// oneof case (Orchestrator::handle_message via OffloadCodec::classify).
message Envelope {
oneof payload {
NodeAdvertisement advertisement = 1; // defined for future use;
// discovery uses the packed
// 72-byte UDP advert instead
OffloadRequest offload_request = 2;
OffloadResponse offload_response = 3;
WorkloadSubmission workload_submission = 4;
WorkloadResult workload_result = 5;
}
}Purpose: Structured event logging as NDJSON (newline-delimited JSON) for offline analysis.
Design Rationale: JSON logs keep the daemon lightweight (no web server, no dashboard), while providing rich, queryable data. Tools like jq, Python scripts, or Grafana (with Loki) can consume NDJSON directly.
Event Types:
// Virtual interface (runtime-configurable sinks)
class ILogSink {
public:
virtual ~ILogSink() = default;
virtual void write(std::string_view json_line) = 0;
virtual void flush() = 0;
};
// Concrete sinks
class JsonFileSink : public ILogSink { /* writes to rotating NDJSON files */ };
class StdoutSink : public ILogSink { /* writes to stdout for debugging */ };
class NullSink : public ILogSink { /* discards — for benchmarking */ };Event Schema (examples):
{"event":"resource_snapshot","ts":"2025-02-13T10:30:00.123Z","node":"rpi-01","cpu_pct":45.2,"mem_avail_mb":1024,"temp_c":52.3,"throttled":false}
{"event":"scheduling_decision","ts":"2025-02-13T10:30:00.456Z","node":"rpi-01","task":"layer_3","assigned_to":"rpi-02","policy":"optimizer","reason":"offload","makespan_est_us":12500}
{"event":"task_completed","ts":"2025-02-13T10:30:01.789Z","node":"rpi-02","task":"layer_3","duration_us":11800,"peak_mem_bytes":2097152,"status":"success"}
{"event":"peer_discovered","ts":"2025-02-13T10:30:00.050Z","node":"rpi-01","peer":"rpi-03","address":"192.168.1.103:5201"}
{"event":"peer_lost","ts":"2025-02-13T10:35:06.000Z","node":"rpi-01","peer":"rpi-03","reason":"heartbeat_timeout"}Metrics Collector:
class MetricsCollector {
public:
explicit MetricsCollector(std::unique_ptr<ILogSink> sink);
void record_resource_snapshot(const ResourceSnapshot& snap);
void record_scheduling_decision(const SchedulingDecision& decision);
void record_task_event(TaskId id, TaskState state, std::chrono::microseconds duration);
void record_peer_event(NodeId peer, std::string_view event_type);
void record_custom(std::string_view event, std::string_view json_payload);
void flush();
private:
std::unique_ptr<ILogSink> sink_;
std::mutex write_mutex_;
};Purpose: Daemon entry point — configuration loading, module initialization, signal handling, and main event loop.
Startup Sequence:
- Parse command-line arguments (
--config,--node-id,--port,--log-dir). - Load TOML configuration file.
- Initialize
MetricsCollectorwith configured log sink. - Initialize
ResourceMonitor(orMockResourceMonitorin simulation mode). - Initialize
ThreadPoolwith configured thread count. - Initialize
NetworkLayer(peer discovery + TCP transport). - Initialize the scheduling policy from configuration (
create_policy()). - Register
SIGINT/SIGTERMhandlers for graceful shutdown viastd::jthreadstop tokens. - Enter main loop: accept workload submissions, trigger scheduling, manage execution.
Configuration File (config.toml):
[node]
id = "rpi-01" # Unique node identifier
port = 5201 # TCP port for inter-node communication
discovery_port = 5200 # UDP broadcast port
[monitor]
sampling_interval_ms = 500 # Resource sampling period
mock = false # Use MockResourceMonitor for testing
[executor]
thread_count = 4 # Worker threads (0 = hardware_concurrency)
memory_pool_mb = 64 # Arena allocator capacity
[scheduler]
policy = "optimizer" # "greedy", "threshold", or "optimizer"
[scheduler.threshold] # ThresholdPolicy-specific config
cpu_threshold_percent = 75.0
memory_threshold_percent = 80.0
[scheduler.optimizer] # OptimizerPolicy-specific config
max_iterations = 100
communication_weight = 0.3 # Weight of network transfer cost
[network]
heartbeat_interval_ms = 2000
peer_timeout_ms = 6000
max_message_size_bytes = 1048576 # 1 MB
[telemetry]
log_dir = "./logs"
max_file_size_mb = 50
rotate_count = 5
log_level = "info" # "debug", "info", "warn", "error"This section maps specific C++20 features to their usage locations, demonstrating deliberate and idiomatic adoption.
| Feature | Module | Usage |
|---|---|---|
| Concepts | core/concepts.hpp |
ResourceMonitorLike (static_asserted by Orchestrator<MonitorT>), SchedulingPolicyLike (static_asserted in the policy tests) — enforced contracts, not decoration |
| Coroutines | core/async.hpp, network/reactor.*, network/async_transport.* |
Async<T> (lazy, symmetric transfer) and DetachedTask drive the epoll-based async offload server: one coroutine per connection |
std::jthread |
executor/, resource_monitor/, network/ |
Cooperative cancellation via std::stop_token; automatic join on destruction (RAII) |
std::counting_semaphore |
executor/thread_pool.hpp |
Idle workers sleep on a semaphore gating the lock-free queue — no busy-waiting on battery-powered hardware |
Atomics + std::bit_ceil |
executor/mpmc_queue.hpp |
Vyukov bounded MPMC ring: per-cell sequence numbers, cache-line-aligned counters, power-of-two capacity |
std::atomic<std::shared_ptr> |
resource_monitor/ |
Lock-free snapshot publication: single writer, many readers, no torn reads |
std::format |
workload/generator.cpp |
Type-safe construction of generated task identifiers |
| Designated Initializers | Throughout | Clean, self-documenting struct construction: TaskProfile{.compute_cost = 1ms, .memory_bytes = 4096} |
Three-way Comparison (<=>) |
core/types.hpp, workload/dag.hpp |
Defaulted comparisons for TaskProfile and Task |
constexpr Extensions |
core/types.hpp |
Compile-time derived quantities (memory_usage_percent, cpu_headroom_percent) |
std::shared_mutex |
network/cluster_view.*, orchestrator/ |
Many-reader cluster view; reader-tasks vs. writer-reset arena discipline |
Result<T, E> (core/result.hpp) is a hand-rolled monadic result over std::variant — std::expected is C++23 and this project targets C++20; the type is designed to migrate to it mechanically.
The project employs a deliberate hybrid approach:
Concepts (Compile-time, zero-cost) — Used where the implementation is chosen at compile time:
ResourceMonitorLike—Orchestrator<MonitorT>is templated on the monitor (LinuxMonitor in production, MockMonitor in tests) and static_asserts the concept.SchedulingPolicyLike— documents the policy contract; enforced by static_asserts in the test suite even though dispatch is virtual (see §4.4 for why).
Virtual Interfaces (Runtime, flexible) — Used for configuration-time decisions and non-hot-path extensibility:
ILogSink— Log destinations are configured at startup and not changed during execution. Virtual dispatch cost is negligible relative to I/O.ISchedulingPolicy— the policy is chosen from TOML at startup (§4.4).AsyncTransport::MessageHandler— the server's request handler is astd::function, bound once atserve().
This hybrid approach ensures maximum performance where it matters while maintaining clean extensibility points.
Algorithm: For each ready task in topological order, assign it to the node (local or peer) with the highest available resource headroom, weighted by the task's profile.
for each task in topological_order(dag):
if task.is_ready():
best_node = argmax over all nodes n:
score(n) = w_cpu * cpu_headroom(n) + w_mem * mem_headroom(n)
- w_net * transfer_cost(task, n)
assign(task, best_node)
Complexity: O(T × N) where T = tasks, N = nodes.
Strengths: Simple, fast, low overhead.
Weaknesses: No global view; may make locally optimal but globally suboptimal decisions.
Algorithm: Execute all tasks locally until a resource threshold is breached. When breached, offload the next ready task to the least-loaded peer. Resume local execution when below threshold.
for each task in topological_order(dag):
if task.is_ready():
if local.cpu_usage < cpu_threshold AND local.mem_usage < mem_threshold:
assign(task, local_node)
else:
peer = least_loaded_peer(cluster)
if peer exists:
assign(task, peer)
else:
queue(task) // Wait for resources
Complexity: O(T × log N) with a sorted peer list.
Strengths: Predictable behavior; easy to tune; minimal network overhead when load is low.
Weaknesses: Reactive (waits until threshold is hit); no predictive scheduling.
Algorithm: Formulates the scheduling problem as a makespan minimization subject to per-node resource constraints. This is inspired by the partitioning formulations in the author's research on DNN/LLM inference partitioning.
Objective: Minimize the total completion time (makespan) of the DAG:
minimize max over all nodes n: completion_time(n)
subject to:
(1) Each task assigned to exactly one node
(2) For each node n: sum of memory(assigned_tasks) <= memory_available(n)
(3) For each node n: sum of compute(assigned_tasks) <= time_budget
(4) Dependency constraints: start_time(task_j) >= end_time(task_i) + comm_cost(i,j)
where comm_cost(i,j) = 0 if same node, else transfer_time(output_i)
Solution Method: Since exact ILP/CP solving is too heavy for real-time embedded use, we implement a fast heuristic:
- Compute the critical path of the DAG (longest-path DP over the topological order).
- Pin critical-path tasks to the local node: the most latency-sensitive chain pays zero transfer cost.
- Distribute remaining tasks using a bin-packing heuristic (First Fit Decreasing by compute cost, subject to per-node memory).
- Apply local search (random single-task moves between nodes, accepted when the estimated makespan improves) for a configurable number of iterations.
The makespan estimate used by the local search implements constraint (4)
directly: a list schedule in topological order where each task starts when
its dependencies' outputs have arrived (cross-node edges add
output_bytes × communication_weight) and its node is free, with one
execution slot per node (conservative on multi-core). This is what lets
the optimizer recognize that a strictly sequential chain gains nothing
from distribution — a property the earlier additive model missed — while
still spreading genuinely parallel stages (see the fan-out comparison in
--demo).
Simplifications vs. the published formulations: no per-link bandwidth
model (communication_weight is a single scalar), single execution slot
per node, hill-climbing without escape from local optima, and a fixed
RNG seed for reproducibility. Each is a deliberate cost/benefit cut for
a sub-millisecond scheduling budget on Pi-class hardware.
Complexity: O(T × N) setup + O((T + E) × I) local search, I = max iterations.
Strengths: Dependency-aware; considers communication costs; refuses useless distribution.
Weaknesses: Heuristic (no optimality bound); requires tuning of iteration count and the communication weight.
For v1.0, we use synthetic workloads that model the computational characteristics of real AI inference without requiring actual model files or inference frameworks. This keeps the build lightweight and focuses the project on orchestration rather than ML runtime concerns.
Each synthetic task simulates work by:
- Allocating
memory_bytesfrom theMemoryPool. - Busy-spinning or performing synthetic compute (matrix multiplications, hash iterations) for
compute_costmicroseconds. - Producing
output_bytesof synthetic output data (random or zeroed).
ExecutionResult SyntheticTaskRunner::execute(const Task& task,
MemoryPool& pool,
std::stop_token stop) {
auto start = std::chrono::steady_clock::now();
// Allocate working memory
void* mem = pool.allocate(task.profile.memory_bytes);
if (!mem) return {task.id, TaskState::Failed, {}, 0, "OOM"};
// Simulate computation (busy work calibrated to target duration)
simulate_compute(task.profile.compute_cost, stop);
auto duration = std::chrono::steady_clock::now() - start;
pool.reset();
return {
.task_id = task.id,
.final_state = TaskState::Completed,
.actual_duration = std::chrono::duration_cast<std::chrono::microseconds>(duration),
.peak_memory_bytes = task.profile.memory_bytes,
.error_message = std::nullopt
};
}| Topology | Description | AI Analogy |
|---|---|---|
| Linear Chain | T1 → T2 → T3 → ... → Tn | Sequential layer inference (no parallelism) |
| Fan-out/Fan-in | T1 → {T2a, T2b, T2c} → T3 | Parallel attention heads, multi-branch architectures |
| Diamond | Repeated fan-out/fan-in at each depth level | Multi-layer parallel inference |
| Transformer | Attention + FFN per layer, with KV-cache memory scaling | Realistic transformer LLM inference |
[workload.preset]
type = "transformer" # "chain", "fan_out_fan_in", "diamond", "transformer", "random"
num_layers = 12
hidden_dim = 768
kv_cache_per_layer_kb = 512
compute_per_layer_ms = 5┌─────────────────────────────┐
│ Application │
├─────────────────────────────┤
│ Cluster View (state) │
├──────────────┬──────────────┤
│ Peer │ Task │
│ Discovery │ Offloading │
│ (UDP bcast) │ (TCP p2p) │
├──────────────┴──────────────┤
│ Protobuf Serialization │
├─────────────────────────────┤
│ Linux Sockets API │
└─────────────────────────────┘
Peer Discovery (UDP, packed 72-byte advert — not Protobuf):
Node A Network (broadcast) Node B
│ │
├──► AdvertPacket ───────► [broadcast 255.255.255.255] ──► │
│ "EORC" v2 | node_id | │
│ tcp_port | cpu% | │
│ mem avail/total | │
│ temp | flags (all big-endian) │
│ │
│ ◄── AdvertPacket ◄────── [broadcast] ◄───────────────────┤
│ │
│ (receiver also records the sender's IP: that plus │
│ tcp_port is the endpoint used for offloading) │
Task Offloading (TCP):
Node A (requester) Node B (executor)
│ │
├──► [TCP connect to B:5201] ──► │
├──► OffloadRequest ──► │
│ {task_id: "layer_5", │
│ profile: {...}, ├──► execute task locally
│ input_data: <bytes>} │
│ │
│ ◄── OffloadResponse ◄── ┤
│ {task_id: "layer_5", │
│ success: true, │
│ output_data: <bytes>, │
│ actual_duration_us: 4800} │
All TCP messages use length-prefixed framing around a Protobuf Envelope:
┌──────────────┬──────────────────────────┐
│ 4 bytes │ N bytes │
│ (uint32 BE) │ (Protobuf Envelope) │
│ = N │ │
└──────────────┴──────────────────────────┘
N is validated against a 16 MB cap before the payload buffer is
allocated (both transports), so a malicious length prefix cannot exhaust
memory; truncated or malformed frames produce a clean error and a closed
connection, never a crash. One request/response pair per connection —
request_id exists in the schema for future pipelined transports.
cmake_minimum_required(VERSION 3.22)
project(EdgeOrchestrator VERSION 1.0.0 LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF)
# Options
option(BUILD_TESTS "Build unit and integration tests" ON)
option(BUILD_TOOLS "Build Python analysis tools" OFF)
option(ENABLE_SANITIZERS "Enable ASan + UBSan" OFF)
option(CROSS_COMPILE_ARM "Cross-compile for ARM64 (RPi)" OFF)
# Dependencies (via FetchContent or find_package)
find_package(Protobuf REQUIRED)
find_package(GTest REQUIRED) # if BUILD_TESTS
# Protobuf code generation
protobuf_generate_cpp(PROTO_SRCS PROTO_HDRS proto/protocol.proto)
# Library targets (one per module)
add_library(eo_core STATIC src/core/...)
add_library(eo_resource_monitor STATIC src/resource_monitor/...)
add_library(eo_workload STATIC src/workload/...)
add_library(eo_scheduler STATIC src/scheduler/...)
add_library(eo_executor STATIC src/executor/...)
add_library(eo_network STATIC src/network/... ${PROTO_SRCS})
add_library(eo_telemetry STATIC src/telemetry/...)
# Main daemon
add_executable(edge_orchestrator src/app/main.cpp)
target_link_libraries(edge_orchestrator
eo_core eo_resource_monitor eo_workload eo_scheduler
eo_executor eo_network eo_telemetry
protobuf::libprotobuf pthread)ARM64 Cross-Compilation (toolchain-aarch64.cmake):
set(CMAKE_SYSTEM_NAME Linux)
set(CMAKE_SYSTEM_PROCESSOR aarch64)
set(CMAKE_C_COMPILER aarch64-linux-gnu-gcc)
set(CMAKE_CXX_COMPILER aarch64-linux-gnu-g++)
set(CMAKE_FIND_ROOT_PATH /usr/aarch64-linux-gnu)
set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER)
set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY)
set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY)name: CI
on: [push, pull_request]
jobs:
build-x86:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- name: Install dependencies
run: |
sudo apt-get update
sudo apt-get install -y protobuf-compiler libprotobuf-dev
- name: Configure
run: cmake -B build -DCMAKE_BUILD_TYPE=Release -DBUILD_TESTS=ON
- name: Build
run: cmake --build build -j$(nproc)
- name: Test
run: cd build && ctest --output-on-failure
build-arm64:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- name: Install cross-compiler
run: |
sudo apt-get update
sudo apt-get install -y g++-aarch64-linux-gnu \
protobuf-compiler libprotobuf-dev
- name: Configure (cross-compile)
run: cmake -B build-arm -DCMAKE_TOOLCHAIN_FILE=cmake/toolchain-aarch64.cmake
- name: Build
run: cmake --build build-arm -j$(nproc)
sanitizers:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- name: Install dependencies
run: sudo apt-get install -y protobuf-compiler libprotobuf-dev
- name: Build with sanitizers
run: |
cmake -B build-san -DENABLE_SANITIZERS=ON -DBUILD_TESTS=ON
cmake --build build-san -j$(nproc)
- name: Test with ASan + UBSan
run: cd build-san && ctest --output-on-failure178 tests, all passing. Total test time: ~8 seconds on a single machine.
| Test Target | Tests | Focus |
|---|---|---|
test_core |
16 | Result<T,E> monadic operations, ResourceSnapshot helpers, TOML config parsing/defaults/errors |
test_workload |
49 | DAG construction, topological sort, cycle detection, critical path, 5 workload generators |
test_executor |
15 | Thread pool submission and concurrency; memory pool allocation/reset/OOM; MPMC queue FIFO, full-rejection, wraparound, 4×4 stress |
test_monitor |
13 | LinuxMonitor (/proc parsing, threshold callbacks), MockMonitor (static + sequence modes) |
test_scheduler |
22 | All 3 policies, ClusterView helpers, cross-policy comparison, makespan-model properties (chain vs fan-out), concept static_asserts |
test_network |
30 | ClusterViewManager (CRUD, thread safety), TCP round-trip (1MB messages), AsyncTransport (echo, concurrency, oversized-frame rejection), UDP discovery + eviction, advert layout pinning |
test_orchestrator |
22 | OffloadCodec round-trips, orchestrator-driven offload to a live peer, fallback on dead/dying peers, workload submission over TCP, arena reset regression |
test_integration |
11 | Full pipeline (Monitor → Scheduler → Executor), two-node TCP offload, E2E orchestration |
| Module | Test Focus |
|---|---|
core/ |
Result<T,E> monadic operations (map, and_then, value_or), ResourceSnapshot helper methods (memory_usage_percent, cpu_headroom), TOML config parsing with full, partial, malformed, and missing files |
resource_monitor/ |
MockMonitor returns expected snapshots in both static and sequence modes; LinuxMonitor reads valid CPU/memory ranges from /proc; threshold callbacks fire when usage exceeds limits |
workload/ |
DAG construction, topological sort, cycle detection, critical path computation, peak memory estimation; all 5 generators produce valid DAGs with correct task counts and dependencies |
scheduler/ |
Each policy produces valid plans; respects resource constraints; handles edge cases (empty cluster, single node); optimizer critical path lands on local node; local search improves makespan; cross-policy comparison verifies all assign all tasks |
executor/ |
Thread pool submit, concurrent execution, thread count verification; MemoryPool allocation, reset, capacity enforcement, OOM handling |
network/ |
ClusterViewManager CRUD, mark_unreachable, thread-safety (10 writers + 5 readers); TCP transport: echo round-trip, 1MB messages, empty messages, reconnection, error cases; PeerDiscovery: self-filtering, eviction on timeout, resource propagation |
orchestrator/ |
OffloadCodec request/response round-trips with empty/large payloads and truncated data; Orchestrator<MockMonitor> construct/start/stop/double-start; workload submission with greedy/threshold/optimizer; offloading with simulated peers |
telemetry/ |
JSON event formatting, NullSink no-op performance |
- Single-node end-to-end: Submit a workload DAG → scheduler assigns all locally → executor runs all tasks in ThreadPool → verify all completed.
- Full pipeline: MockMonitor → GreedyPolicy → ThreadPool execution of 5-task linear chain → verify 5/5 completed.
- Transformer workload: Generate 8-task transformer DAG → OptimizerPolicy schedule → parallel execution → verify 8/8 completed.
- Threshold with peers: MockMonitor at 85% CPU → ThresholdPolicy offloads to simulated peer → verify offloading occurs.
- TCP offload round-trip: Server decodes OffloadCodec request, returns response → client verifies success status and payload.
- Discovery and cluster view: Two PeerDiscovery instances on same port → both discover each other → ClusterViewManager populated with correct resource values → schedule using discovered cluster.
- Full E2E orchestration: Config → LinuxMonitor → ClusterViewManager → WorkloadGenerator::diamond → GreedyPolicy → ThreadPool → MetricsCollector → verify all tasks complete.
- Demo mode equivalent: Programmatically replicate
--demopath — transformer workload through all three policies → verify all 8 tasks assigned by each. - Telemetry integration: Record resource snapshots, scheduling decisions, task events, peer events, and custom events through MetricsCollector.
bench_scheduler— time to produce aSchedulingPlanfor DAGs of varying size across varying cluster sizes, per policy:./build/tests/bench_scheduler [--csv]bench_queue— lock-free MPMC ring vs. the previous mutex+std::queuehand-off under 1×1 … 8×8 producer/consumer contention:./build/tests/bench_queue [ops]
Planned (not yet implemented): executor task throughput and offload round-trip latency decomposition on Pi-class hardware.
- Design document (46KB, 13 sections)
- Core types,
Result<T,E>monad, concepts, config, logger - Resource monitor — LinuxMonitor (
/proc,/sys) and MockMonitor - Workload model — DAG engine with 5 topology generators
- Three scheduling policies — Greedy, Threshold, Optimizer
- Thread pool executor with memory pool and task runner
- UDP peer discovery with heartbeat, eviction, and endian-safe v2 wire format
- TCP transport with length-prefixed framing and 16 MB pre-allocation cap
- Async offload server — C++20 coroutines over an epoll reactor, concurrent connections
- Protobuf Envelope wire protocol (offload + workload submission/result)
- End-to-end offloading: endpoint resolution from discovery, real TCP round trip, local fallback on peer death
- Workload submission over the wire (
tools/workload_injector.pyspeaks the same .proto) - Thread-safe ClusterViewManager with peer endpoint records
- Lock-free MPMC task queue with semaphore-gated workers (benchmarked vs mutex baseline)
- Orchestrator facade — template-parameterized, full lifecycle, single composition root for the daemon
- NDJSON telemetry with pluggable sinks; separate metrics stream in the daemon
- Daemon application with TOML config, CLI args,
--demomode (chain vs fan-out policy comparison) - 178 unit + integration tests, all passing
- CMake build system with ARM64 cross-compilation toolchain
- GitHub Actions CI pipeline (x86 Debug/Release, ASan+UBSan, ARM64 cross-compile)
- API reference documentation
- Comprehensive README with architecture diagrams
- Integration with ONNX Runtime or TensorFlow Lite for real model inference
- TinyLlama / GPT-2 layer-by-layer execution on Raspberry Pi testbed
- KV-cache-aware dynamic memory management during autoregressive generation
- Dynamic re-partitioning during inference based on runtime resource snapshots
- Cross-layer similarity discovery for speculative execution (PhD research extension)
- Web dashboard (WebSocket + lightweight frontend)
- Prometheus metrics exporter for Grafana integration
- Kubernetes-style health probes and liveness checks
- Support for heterogeneous nodes (different ARM variants, x86 edge servers)
- Energy-aware scheduling for battery-powered edge devices
- Protobuf-based task offloading (shipped in v1.x — replaced the original binary codec)
- Authentication and transport security (mTLS on TCP, signed advertisements)
- Partition/rejoin semantics for split-brain recovery
- D. Kafetzis, I. Koutsopoulos — Research on dynamic partitioning and resource orchestration for AI model deployment in resource-constrained wireless edge networks. Athens University of Economics and Business. Publications at IEEE/IFIP WiOpt, ACM MobiHoc, IEEE MILCOM, IEEE MeditCom.
- ISO/IEC 14882:2020 — Programming Languages — C++ (C++20 Standard).
- Protocol Buffers Language Guide — https://protobuf.dev/programming-guides/proto3/
- Raspberry Pi Hardware Documentation — https://www.raspberrypi.com/documentation/
- Linux
/procFilesystem Documentation — https://man7.org/linux/man-pages/man5/proc.5.html - Linux
/sysThermal Zone Interface — https://www.kernel.org/doc/Documentation/thermal/sysfs-api.txt - J. Duato, S. Yalamanchili, L. Ni — "Interconnection Networks: An Engineering Approach." Morgan Kaufmann, 2003.
- R. L. Graham — "Bounds on Multiprocessing Timing Anomalies." SIAM Journal on Applied Mathematics, 1969.
- GoogleTest Framework — https://github.com/google/googletest
- toml++ Library — https://github.com/marzer/tomlplusplus
- nlohmann/json Library — https://github.com/nlohmann/json
Design document v1.0 — Implementation complete. Author: Dimitris Kafetzis, PhD Candidate, Athens University of Economics and Business.