feat(utils/dependencies): Added computational graph system
The initial prototype for this code used a number of bespoke so-called "prepared" operators to avoid recomputation of values which we could be sure would not change. However, this system was hard to scale. Here we implement an equivalent, but much more scalable, solution through a computational graph which keeps track of inputs and outputs to each node and only reruns the node when the inputs have changed. Note that this is a simplistic optimization scheme, we are do not check for example if the outputs change strongly or even at all with different inputs (generally that would require derivatives of the outputs wrt. each input) nor do we do any sort of graph optimization.
This commit is contained in:
@@ -0,0 +1,92 @@
|
||||
#pragma once
|
||||
|
||||
#include <array>
|
||||
#include <type_traits>
|
||||
#include "serif/utils/misc/traits/type_uniqueness.hpp"
|
||||
|
||||
namespace serif::utils::dependencies {
|
||||
template <typename KeyT, typename TargetT>
|
||||
struct Binding {
|
||||
using Key = KeyT;
|
||||
using Target = TargetT;
|
||||
};
|
||||
|
||||
template <typename T>
|
||||
concept IsBinding = requires {
|
||||
typename T::Key;
|
||||
typename T::Target;
|
||||
} && std::is_same_v<T, Binding<typename T::Key, typename T::Target>>;
|
||||
|
||||
namespace detail {
|
||||
/** @brief Validate each binding without an implicit constraint fold proportional to the collection size. */
|
||||
template <typename... Types>
|
||||
inline constexpr bool valid_binding_types_v = [] {
|
||||
constexpr std::array<bool, sizeof...(Types)> valid{IsBinding<Types>...};
|
||||
for (const bool binding : valid) {
|
||||
if (!binding) { return false; }
|
||||
}
|
||||
return true;
|
||||
}();
|
||||
}
|
||||
|
||||
template <typename... BindingTs> requires detail::valid_binding_types_v<BindingTs...>
|
||||
struct Bindings {};
|
||||
|
||||
template <typename T>
|
||||
inline constexpr bool is_bindings_v = false;
|
||||
|
||||
template <typename... BindingTs> requires detail::valid_binding_types_v<BindingTs...>
|
||||
inline constexpr bool is_bindings_v<Bindings<BindingTs...>> = misc::traits::all_unique_types_v<typename BindingTs::Key...>;
|
||||
|
||||
template <typename T>
|
||||
concept IsBindings = is_bindings_v<std::remove_cvref_t<T>>;
|
||||
|
||||
namespace detail {
|
||||
template <typename BindingsT, typename KeyT>
|
||||
struct HasBinding;
|
||||
|
||||
template <typename KeyT, typename... BindingTs> requires valid_binding_types_v<BindingTs...>
|
||||
struct HasBinding<Bindings<BindingTs...>, KeyT> {
|
||||
constexpr static bool value = [] {
|
||||
constexpr std::array<bool, sizeof...(BindingTs)> matches{std::is_same_v<KeyT, typename BindingTs::Key>...};
|
||||
for (const bool match : matches) {
|
||||
if (match) { return true; }
|
||||
}
|
||||
return false;
|
||||
}();
|
||||
};
|
||||
|
||||
template <typename BindingsT>
|
||||
struct BindingLookup;
|
||||
|
||||
/** @brief Share one compile-time lookup structure across all keys in a binding collection. */
|
||||
template <typename... BindingTs> requires valid_binding_types_v<BindingTs...>
|
||||
struct BindingLookup<Bindings<BindingTs...>> : BindingTs... {
|
||||
/** @brief Select the matching base type without constructing any bound target. */
|
||||
template <typename KeyT, typename TargetT>
|
||||
static Binding<KeyT, TargetT> select(Binding<KeyT, TargetT>);
|
||||
};
|
||||
|
||||
template <typename BindingsT, typename KeyT>
|
||||
struct BoundType {
|
||||
using Lookup = BindingLookup<BindingsT>;
|
||||
using target_type = decltype(Lookup::template select<KeyT>(Lookup{}))::Target;
|
||||
};
|
||||
}
|
||||
|
||||
template <IsBindings BindingsT, typename KeyT>
|
||||
struct HasBinding : detail::HasBinding<std::remove_cvref_t<BindingsT>, KeyT> {};
|
||||
|
||||
template <IsBindings BindingsT, typename KeyT>
|
||||
inline constexpr bool has_binding_v = HasBinding<BindingsT, KeyT>::value;
|
||||
|
||||
template <typename BindingsT, typename KeyT>
|
||||
concept BindingsWithBinding = IsBindings<BindingsT> && has_binding_v<BindingsT, KeyT>;
|
||||
|
||||
template <typename BindingsT, typename KeyT>
|
||||
requires BindingsWithBinding<BindingsT, KeyT>
|
||||
struct BoundType : detail::BoundType<std::remove_cvref_t<BindingsT>, KeyT> {};
|
||||
|
||||
template <typename BindingsT, typename KeyT>
|
||||
using bound_type_t = BoundType<BindingsT, KeyT>::target_type;
|
||||
}
|
||||
|
||||
58
src/include/serif/utils/dependencies/computation.hpp
Normal file
58
src/include/serif/utils/dependencies/computation.hpp
Normal file
@@ -0,0 +1,58 @@
|
||||
#pragma once
|
||||
|
||||
#include <concepts>
|
||||
#include <functional>
|
||||
#include <type_traits>
|
||||
#include <utility>
|
||||
#include "serif/utils/dependencies/key_list.hpp"
|
||||
|
||||
namespace serif::utils::dependencies {
|
||||
template <typename KeyT, IsKeyList InputsT, typename FunctionT, bool OwnsInputs = false>
|
||||
class Computation {
|
||||
public:
|
||||
using Key = KeyT;
|
||||
using Inputs = std::remove_cvref_t<InputsT>;
|
||||
using Function = FunctionT;
|
||||
static constexpr bool owns_inputs = OwnsInputs;
|
||||
|
||||
/// Own the callable; result-affecting mutable state belongs in the declared inputs.
|
||||
explicit Computation(FunctionT function) : m_function(std::move(function)) {
|
||||
}
|
||||
|
||||
/// Supply inputs in declaration order and infer the result from the callable.
|
||||
template <typename... Arguments> requires std::invocable<const FunctionT&, const Arguments&...>
|
||||
[[nodiscard]] decltype(auto) evaluate(const Arguments&... arguments) const {
|
||||
return std::invoke(m_function, arguments...);
|
||||
}
|
||||
|
||||
private:
|
||||
FunctionT m_function;
|
||||
};
|
||||
|
||||
template <typename T>
|
||||
inline constexpr bool is_computation_v = false;
|
||||
|
||||
template <typename KeyT, typename InputsT, typename FunctionT, bool OwnsInputs>
|
||||
inline constexpr bool is_computation_v<Computation<KeyT, InputsT, FunctionT, OwnsInputs>> = true;
|
||||
|
||||
template <typename T>
|
||||
concept IsComputation = is_computation_v<std::remove_cvref_t<T>>;
|
||||
|
||||
/// Attach an output key and ordered input keys to a lambda, function, or other callable.
|
||||
template <typename KeyT, IsKeyList InputsT, typename FunctionT>
|
||||
requires std::constructible_from<std::decay_t<FunctionT>, FunctionT>
|
||||
[[nodiscard]] auto make_computation(FunctionT&& function) {
|
||||
return Computation<KeyT, std::remove_cvref_t<InputsT>, std::decay_t<FunctionT>>(std::forward<FunctionT>(function));
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Supply immutable shared handles to computed prerequisites so results can retain them without copying.
|
||||
* @note Every input must be produced by this graph. Context sources remain borrowed and cannot be retained this way.
|
||||
* Resources borrowed inside a prerequisite must still outlive their use; the handle owns only the computed result.
|
||||
*/
|
||||
template <typename KeyT, IsKeyList InputsT, typename FunctionT>
|
||||
requires std::constructible_from<std::decay_t<FunctionT>, FunctionT>
|
||||
[[nodiscard]] auto make_owning_computation(FunctionT&& function) {
|
||||
return Computation<KeyT, std::remove_cvref_t<InputsT>, std::decay_t<FunctionT>, true>(std::forward<FunctionT>(function));
|
||||
}
|
||||
}
|
||||
331
src/include/serif/utils/dependencies/computation_cache.hpp
Normal file
331
src/include/serif/utils/dependencies/computation_cache.hpp
Normal file
@@ -0,0 +1,331 @@
|
||||
#pragma once
|
||||
|
||||
#include "serif/utils/error/errors.hpp"
|
||||
#include <array>
|
||||
#include <concepts>
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <expected>
|
||||
#include <memory>
|
||||
#include <stdexcept>
|
||||
#include <string>
|
||||
#include <tuple>
|
||||
#include <type_traits>
|
||||
#include <utility>
|
||||
#include "serif/utils/dependencies/preparation.hpp"
|
||||
#include "serif/utils/dependencies/observers.hpp"
|
||||
#include "serif/utils/dependencies/detail/cache_storage.hpp"
|
||||
|
||||
namespace serif::utils::dependencies {
|
||||
/** @brief Execute a computational graph on demand, publishing immutable results only after the entire request succeeds. */
|
||||
template <typename GraphT, typename ContextT, typename ErrorT = std::string, typename ObserversT = Observers<>>
|
||||
requires CanPrepare<GraphT, ContextT, ErrorT> && ObserversFor<ObserversT, GraphT, ContextT>
|
||||
class ComputationCache {
|
||||
public:
|
||||
using Traits = detail::ComputationCacheTraits<GraphT, ContextT, ErrorT>;
|
||||
using Bindings = typename Traits::Bindings;
|
||||
using Sources = typename Traits::Sources;
|
||||
using Report = PreparationReport<typename GraphT::Results, Sources>;
|
||||
using Failure = PreparationFailure<ErrorT>;
|
||||
|
||||
/// Own the graph, observer callbacks, and accepted cache; each preparation borrows its context.
|
||||
explicit ComputationCache(GraphT graph, ObserversT observers = ObserversT{}) :
|
||||
m_graph(std::move(graph)), m_observers(std::move(observers)) {
|
||||
}
|
||||
|
||||
ComputationCache(const ComputationCache&) = delete;
|
||||
ComputationCache& operator=(const ComputationCache&) = delete;
|
||||
|
||||
/// Transfer accepted results while leaving the original object unprepared.
|
||||
ComputationCache(ComputationCache&& other) :
|
||||
m_graph(std::move(other.m_graph)),
|
||||
m_observers(std::move(other.m_observers)),
|
||||
m_storage(std::move(other.m_storage)),
|
||||
m_report(std::exchange(other.m_report, {})),
|
||||
m_ready(std::exchange(other.m_ready, false)),
|
||||
m_preparation_count(std::exchange(other.m_preparation_count, 0)) {
|
||||
}
|
||||
|
||||
ComputationCache& operator=(ComputationCache&&) = delete;
|
||||
|
||||
/// Prepare the requested results and their prerequisites, publishing only a complete successful candidate.
|
||||
template <IsKeyList RequestedT = GraphT::Results>
|
||||
requires detail::key_list_subset_v<std::remove_cvref_t<RequestedT>, typename GraphT::Results>
|
||||
[[nodiscard]] std::expected<Report, Failure> try_prepare(
|
||||
const ContextT& context,
|
||||
RequestedT = {},
|
||||
const PreparationMode mode = PreparationMode::reuse_unchanged
|
||||
) {
|
||||
clear_preparation();
|
||||
|
||||
Storage candidate = m_storage;
|
||||
Report report;
|
||||
std::array<bool, source_count> inspected{};
|
||||
|
||||
constexpr auto required = GraphT::Dependencies::required(std::remove_cvref_t<RequestedT>{});
|
||||
auto outcome = prepare_in_order<0>(context, candidate, report, inspected, required, mode);
|
||||
|
||||
if (!outcome) {
|
||||
return std::unexpected(std::move(outcome.error()));
|
||||
}
|
||||
|
||||
m_storage = std::move(candidate);
|
||||
m_report = report;
|
||||
m_ready = true;
|
||||
++m_preparation_count;
|
||||
|
||||
return report;
|
||||
}
|
||||
|
||||
/// Test whether a result belongs to the latest successful request, including its prerequisites.
|
||||
template <typename KeyT>
|
||||
requires detail::contains_key_v<KeyT, typename GraphT::Results>
|
||||
[[nodiscard]] bool contains() const {
|
||||
return m_ready && m_report.template status<KeyT>() != PreparationStatus::not_required;
|
||||
}
|
||||
|
||||
/// Read a frozen result. References remain valid until the next preparation or invalidation.
|
||||
template <typename KeyT>
|
||||
requires detail::contains_key_v<KeyT, typename GraphT::Results>
|
||||
[[nodiscard]] const auto& get() const {
|
||||
if (!contains<KeyT>()) {
|
||||
serif::utils::errors::serif_error<std::logic_error, serif::utils::errors::SERiFErrorCode::INVALID_ARGUMENT>(
|
||||
"dependencies::ComputationCache::get: this result is unavailable. Successfully prepare requested results "
|
||||
"containing its key or a result that depends on it before reading it."
|
||||
);
|
||||
}
|
||||
|
||||
return *std::get<GraphT::template index<KeyT>()>(m_storage.results).value;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Retain an immutable accepted result independently of subsequent cache preparations.
|
||||
* @note This retains the result itself; resources borrowed by that result must still outlive their use.
|
||||
*/
|
||||
template <typename KeyT>
|
||||
requires detail::contains_key_v<KeyT, typename GraphT::Results>
|
||||
[[nodiscard]] auto share() const {
|
||||
(void)get<KeyT>();
|
||||
return std::get<GraphT::template index<KeyT>()>(m_storage.results).value;
|
||||
}
|
||||
|
||||
/// Report reuse and rebuilding from the latest successful preparation.
|
||||
[[nodiscard]] const Report& report() const noexcept {
|
||||
return m_report;
|
||||
}
|
||||
|
||||
/// A failed preparation makes all result access unavailable until a subsequent success.
|
||||
[[nodiscard]] bool ready() const noexcept {
|
||||
return m_ready;
|
||||
}
|
||||
|
||||
/// Count successful preparations, including requests whose results were all reused.
|
||||
[[nodiscard]] std::size_t preparation_count() const noexcept {
|
||||
return m_preparation_count;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Make results unavailable before validating a new candidate's inputs.
|
||||
* @note Accepted snapshots remain reusable after rejection; only a successful preparation
|
||||
* restores public access. Owning handles obtained earlier remain valid.
|
||||
*/
|
||||
void clear_preparation() noexcept {
|
||||
m_report = {};
|
||||
m_ready = false;
|
||||
}
|
||||
|
||||
/// Discard accepted snapshots and results, requiring fresh preparation on the next request.
|
||||
void invalidate() noexcept {
|
||||
m_storage = {};
|
||||
clear_preparation();
|
||||
}
|
||||
|
||||
private:
|
||||
static constexpr std::size_t source_count = detail::KeyListSize<Sources>::value;
|
||||
using Storage = detail::ComputationCacheStorage<GraphT, Bindings, typename Traits::Resources, std::make_index_sequence<GraphT::size>>;
|
||||
using ResultMask = typename GraphT::Dependencies::Mask;
|
||||
using SourceMask = std::array<bool, source_count>;
|
||||
|
||||
/// Share one version namespace between source snapshots and computed results.
|
||||
template <typename KeyT>
|
||||
[[nodiscard]] static consteval std::size_t version_index() {
|
||||
if constexpr (detail::contains_key_v<KeyT, Sources>) {
|
||||
return detail::type_index<KeyT>(typename Sources::Keys{});
|
||||
} else {
|
||||
return source_count + GraphT::template index<KeyT>();
|
||||
}
|
||||
}
|
||||
|
||||
/// Inspect each required source once; other context objects need no snapshot policy.
|
||||
template <typename KeyT>
|
||||
void inspect_source(
|
||||
const ContextT& context,
|
||||
Storage& candidate,
|
||||
Report& report,
|
||||
SourceMask& inspected,
|
||||
const PreparationMode mode
|
||||
) const {
|
||||
if constexpr (detail::contains_key_v<KeyT, Sources>) {
|
||||
constexpr std::size_t index = version_index<KeyT>();
|
||||
|
||||
if (inspected[index]) {
|
||||
return;
|
||||
}
|
||||
|
||||
inspected[index] = true;
|
||||
|
||||
const auto& resource = context.template resource<KeyT>();
|
||||
auto& snapshot = std::get<index>(candidate.sources);
|
||||
|
||||
const void* identity = source_identity(resource.policy(), resource.get());
|
||||
const bool changed = mode == PreparationMode::rebuild || !snapshot.captured || snapshot.identity != identity || !resource.policy().matches(*snapshot.captured, resource.get());
|
||||
|
||||
if (changed) {
|
||||
using Captured = typename std::remove_reference_t<decltype(snapshot)>::Captured;
|
||||
snapshot.captured = std::make_shared<const Captured>(resource.policy().capture(resource.get()));
|
||||
snapshot.identity = identity;
|
||||
++candidate.versions[index];
|
||||
report.changed_sources[index] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Sources and computed prerequisites expose the same read-only values to edge observers.
|
||||
template <typename KeyT>
|
||||
[[nodiscard]] const auto& input_value(const ContextT& context, const Storage& candidate) const {
|
||||
if constexpr (detail::contains_key_v<KeyT, Sources>) {
|
||||
return context.template get<KeyT>();
|
||||
} else {
|
||||
return *std::get<GraphT::template index<KeyT>()>(candidate.results).value;
|
||||
}
|
||||
}
|
||||
|
||||
/// Only computed prerequisites have immutable owning handles; ordinary inputs remain references.
|
||||
template <typename ComputationT, typename KeyT>
|
||||
[[nodiscard]] const auto& input(const ContextT& context, const Storage& candidate) const {
|
||||
if constexpr (ComputationT::owns_inputs) {
|
||||
return std::get<GraphT::template index<KeyT>()>(candidate.results).value;
|
||||
} else {
|
||||
return input_value<KeyT>(context, candidate);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Execute one node after its inputs are ready and reuse has been ruled out.
|
||||
* @note This returns a candidate; it does not publish results or change the accepted cache.
|
||||
*/
|
||||
template <std::size_t Index, typename... Keys>
|
||||
[[nodiscard]] auto evaluate_node(
|
||||
const ContextT& context,
|
||||
const Storage& candidate,
|
||||
KeyList<Keys...>
|
||||
) {
|
||||
using Computation = GraphT::template ComputationAt<Index>;
|
||||
const auto& computation = m_graph.template computation<Index>();
|
||||
|
||||
return m_observers.template evaluate<typename Computation::Key>([&]() {
|
||||
// A comma fold makes edge callbacks follow Inputs order, unlike function arguments.
|
||||
(m_observers.template edge<Keys, typename Computation::Key>(input_value<Keys>(context, candidate)), ...);
|
||||
|
||||
return computation.evaluate(input<Computation, Keys>(context, candidate)...);
|
||||
});
|
||||
}
|
||||
|
||||
/// Reuse a result only when every input version matches its accepted version.
|
||||
template <std::size_t Index, typename... Keys>
|
||||
[[nodiscard]] std::expected<void, Failure> prepare_one(
|
||||
const ContextT& context,
|
||||
Storage& candidate,
|
||||
Report& report,
|
||||
SourceMask& inspected,
|
||||
const PreparationMode mode,
|
||||
KeyList<Keys...>
|
||||
) {
|
||||
using Computation = GraphT::template ComputationAt<Index>;
|
||||
using Result = detail::ResolveComputation<Computation, Bindings>;
|
||||
|
||||
(inspect_source<Keys>(context, candidate, report, inspected, mode), ...);
|
||||
|
||||
const std::array<std::uint64_t, sizeof...(Keys)> versions{candidate.versions[version_index<Keys>()]...};
|
||||
auto& cached = std::get<Index>(candidate.results);
|
||||
|
||||
if (mode == PreparationMode::reuse_unchanged && cached.value && cached.input_versions == versions) {
|
||||
report.statuses[Index] = PreparationStatus::reused;
|
||||
return {};
|
||||
}
|
||||
|
||||
auto result = evaluate_node<Index>(context, candidate, KeyList<Keys...>{});
|
||||
|
||||
if constexpr (Result::fallible) {
|
||||
if (!result) {
|
||||
return std::unexpected(Failure{
|
||||
.computation_index = Index,
|
||||
.error = std::move(result.error())
|
||||
});
|
||||
}
|
||||
|
||||
cached.value = std::make_shared<const typename Result::Value>(std::move(*result));
|
||||
} else {
|
||||
cached.value = std::make_shared<const typename Result::Value>(std::move(result));
|
||||
}
|
||||
|
||||
cached.input_versions = versions;
|
||||
++candidate.versions[source_count + Index];
|
||||
report.statuses[Index] = PreparationStatus::computed;
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
/// Follow the compile-time order while skipping branches outside the requested closure.
|
||||
template <std::size_t Position>
|
||||
[[nodiscard]] std::expected<void, Failure> prepare_in_order(
|
||||
const ContextT& context,
|
||||
Storage& candidate,
|
||||
Report& report,
|
||||
SourceMask& inspected,
|
||||
const ResultMask& required,
|
||||
PreparationMode mode
|
||||
) {
|
||||
if constexpr (Position < GraphT::size) {
|
||||
constexpr std::size_t index = GraphT::Dependencies::order[Position];
|
||||
if (required[index]) {
|
||||
auto outcome = prepare_one<index>(
|
||||
context, candidate, report, inspected, mode, typename GraphT::template ComputationAt<index>::Inputs{}
|
||||
);
|
||||
if (!outcome) {
|
||||
return outcome;
|
||||
}
|
||||
}
|
||||
|
||||
return prepare_in_order<Position + 1>(context, candidate, report, inspected, required, mode);
|
||||
} else {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
GraphT m_graph;
|
||||
[[no_unique_address]] ObserversT m_observers;
|
||||
Storage m_storage;
|
||||
Report m_report;
|
||||
bool m_ready{false};
|
||||
std::size_t m_preparation_count{0};
|
||||
};
|
||||
|
||||
/// Fix the graph's source types without retaining the supplied context or evaluating any computations.
|
||||
template <typename ErrorT = std::string, IsComputationGraph GraphT, IsDependencyContext ContextT, typename ObserversT = Observers<>>
|
||||
requires CanPrepare<GraphT, ContextT, ErrorT> && ObserversFor<ObserversT, GraphT, ContextT> &&
|
||||
std::constructible_from<std::remove_cvref_t<GraphT>, GraphT> && std::constructible_from<std::remove_cvref_t<ObserversT>, ObserversT>
|
||||
[[nodiscard]] auto make_computation_cache(GraphT&& graph, const ContextT&, ObserversT&& observers = ObserversT{}) {
|
||||
return ComputationCache<std::remove_cvref_t<GraphT>, std::remove_cvref_t<ContextT>, ErrorT, std::remove_cvref_t<ObserversT>>(
|
||||
std::forward<GraphT>(graph), std::forward<ObserversT>(observers));
|
||||
}
|
||||
|
||||
/// Build a checked graph and an empty cache directly from named computations.
|
||||
template <typename ErrorT = std::string, IsDependencyContext ContextT, typename... Computations>
|
||||
requires CompatibleComputations<Computations...> &&
|
||||
(std::constructible_from<std::remove_cvref_t<Computations>, Computations> && ...) &&
|
||||
CanPrepare<ComputationGraph<std::remove_cvref_t<Computations>...>, ContextT, ErrorT>
|
||||
[[nodiscard]] auto make_computation_cache(const ContextT& context, Computations&&... computations) {
|
||||
return make_computation_cache<ErrorT>(make_computation_graph(std::forward<Computations>(computations)...), context);
|
||||
}
|
||||
}
|
||||
76
src/include/serif/utils/dependencies/computation_graph.hpp
Normal file
76
src/include/serif/utils/dependencies/computation_graph.hpp
Normal file
@@ -0,0 +1,76 @@
|
||||
#pragma once
|
||||
|
||||
#include <concepts>
|
||||
#include <cstddef>
|
||||
#include <tuple>
|
||||
#include <type_traits>
|
||||
#include <utility>
|
||||
#include "serif/utils/dependencies/computation.hpp"
|
||||
#include "serif/utils/dependencies/key_list_operations.hpp"
|
||||
#include "serif/utils/dependencies/detail/graph.hpp"
|
||||
|
||||
namespace serif::utils::dependencies {
|
||||
namespace detail {
|
||||
/// Owning nodes can retain computed inputs; context resources remain borrowed.
|
||||
template <typename ComputationT, typename ResultKeysT>
|
||||
inline constexpr bool valid_input_ownership_v = !std::remove_cvref_t<ComputationT>::owns_inputs ||
|
||||
key_list_subset_v<typename std::remove_cvref_t<ComputationT>::Inputs, ResultKeysT>;
|
||||
}
|
||||
|
||||
/// A computational graph needs unique producers, valid input ownership, and no cycles.
|
||||
template <typename... Computations>
|
||||
concept CompatibleComputations =
|
||||
(IsComputation<Computations> && ...) &&
|
||||
misc::traits::all_unique_types_v<typename std::remove_cvref_t<Computations>::Key...> &&
|
||||
(detail::valid_input_ownership_v<Computations, KeyList<typename std::remove_cvref_t<Computations>::Key...>> && ...) &&
|
||||
detail::Graph<std::remove_cvref_t<Computations>...>::acyclic();
|
||||
|
||||
template <IsComputation... Computations> requires CompatibleComputations<Computations...>
|
||||
class ComputationGraph {
|
||||
public:
|
||||
using Results = KeyList<typename Computations::Key...>;
|
||||
using Inputs = CombinedKeyList<typename Computations::Inputs...>;
|
||||
/// Compile-time topology; indices follow declaration order, not execution order.
|
||||
using Dependencies = detail::Graph<Computations...>;
|
||||
static constexpr std::size_t size = sizeof...(Computations);
|
||||
|
||||
/// Select a node by its declaration index; Dependencies::order gives execution order.
|
||||
template <std::size_t Index>
|
||||
using ComputationAt = std::tuple_element_t<Index, std::tuple<Computations...>>;
|
||||
|
||||
/// Accept computations in any order; the graph determines their evaluation order.
|
||||
explicit ComputationGraph(Computations... computations) : m_computations(std::move(computations)...) {}
|
||||
|
||||
/// Read a callable without permitting its stored configuration to change between preparations.
|
||||
template <std::size_t Index> requires (Index < size)
|
||||
[[nodiscard]] const auto& computation() const {
|
||||
return std::get<Index>(m_computations);
|
||||
}
|
||||
|
||||
/// Convert a known result key to its stable declaration index.
|
||||
template <typename KeyT> requires detail::contains_key_v<KeyT, Results>
|
||||
[[nodiscard]] static consteval std::size_t index() {
|
||||
return detail::type_index<KeyT>(typename Results::Keys{});
|
||||
}
|
||||
|
||||
private:
|
||||
std::tuple<Computations...> m_computations;
|
||||
};
|
||||
|
||||
template <typename T>
|
||||
inline constexpr bool is_computation_graph_v = false;
|
||||
|
||||
template <typename... Computations>
|
||||
inline constexpr bool is_computation_graph_v<ComputationGraph<Computations...>> = true;
|
||||
|
||||
template <typename T>
|
||||
concept IsComputationGraph = is_computation_graph_v<std::remove_cvref_t<T>>;
|
||||
|
||||
/// Reject duplicate producers and cyclic computational graphs at the declaration site.
|
||||
template <typename... Computations>
|
||||
requires CompatibleComputations<Computations...> &&
|
||||
(std::constructible_from<std::remove_cvref_t<Computations>, Computations> && ...)
|
||||
[[nodiscard]] auto make_computation_graph(Computations&&... computations) {
|
||||
return ComputationGraph<std::remove_cvref_t<Computations>...>(std::forward<Computations>(computations)...);
|
||||
}
|
||||
}
|
||||
170
src/include/serif/utils/dependencies/dependencies.hpp
Normal file
170
src/include/serif/utils/dependencies/dependencies.hpp
Normal file
@@ -0,0 +1,170 @@
|
||||
#pragma once
|
||||
|
||||
/**
|
||||
* @file dependencies.hpp
|
||||
* @author Emily M. Boudreaux
|
||||
* @brief This file is a convenience header which includes all of the headers in the dependencies module. Further, we include the detailed
|
||||
* documentation for the dependencies module here.
|
||||
*
|
||||
*/
|
||||
|
||||
#include "serif/utils/dependencies/keys.hpp"
|
||||
#include "serif/utils/dependencies/binding.hpp"
|
||||
#include "serif/utils/dependencies/key_list.hpp"
|
||||
#include "serif/utils/dependencies/key_list_operations.hpp"
|
||||
#include "serif/utils/dependencies/dependency_context.hpp"
|
||||
#include "serif/utils/dependencies/computation.hpp"
|
||||
#include "serif/utils/dependencies/computation_graph.hpp"
|
||||
#include "serif/utils/dependencies/observers.hpp"
|
||||
#include "serif/utils/dependencies/computation_cache.hpp"
|
||||
#include "serif/utils/dependencies/snapshot.hpp"
|
||||
|
||||
/**
|
||||
* *Computational Graphs*
|
||||
*
|
||||
* Demonstration code for how to use the SERiF computational graph system
|
||||
* The broad idea here is that there are many calculations which depend on each other but which may not necessarily always need to be recomputed just
|
||||
* because another part of the model has changed. We model this as a combination of an acyclic computational graph, defined at compile time, and
|
||||
* a runtime cache of values. The general workflow is that a developer defines the "context" of the computational graph, think of this
|
||||
* as all of the inputs to the graph, or maybe more specifically, anything that could change due to some external event (e.g. a time step, a newton step, etc...)
|
||||
* This context is probably the most CS intensive part of this code as the developer needs to think about lifetimes and ownership.
|
||||
*
|
||||
* **Context**
|
||||
* Contexts are made with the make_dependency_context function, this accepts a variadic pack of bindings between a type and a runtime value.
|
||||
* You can generate these yourself using the binding functions and types; however, we provide two convenience wrappers which generally developers should consider
|
||||
* using.
|
||||
* - borrow: Used to bind a type to a reference to a value. This is useful for values which are owned by the caller and which the caller will manage the lifetime of (takes an lvalue).
|
||||
* - own: Used to bind a type to a value which is owned by the context (copies an lvalue or moves an rvalue)
|
||||
* Each of these functions accepts a type as a template parameter and a runtime value as an argument. The type is used as an ID, this is key to understand, if we write
|
||||
* \code{.cpp}
|
||||
* using namespace serif::utils::dependencies;
|
||||
*
|
||||
* double x{1.0};
|
||||
* auto context = make_dependency_context(borrow<keys::Input>(x));
|
||||
* \endcode
|
||||
*
|
||||
* we are not saying that x is of type keys::Input, this is clearly not the case. Rather, we are registering in the computational graph a binding
|
||||
* between the type keys::Input and the runtime value x. Think of this as naming the various inputs to the graph.
|
||||
*
|
||||
* **Computations**
|
||||
* After defining a context we can define a computation, each computation defines a node, while its ordered input keys define the incoming edges.
|
||||
* Consider the following example (assuming the context from above):
|
||||
*
|
||||
* \code{.cpp}
|
||||
* auto func = make_computation<keys::Output, InputList<keys::Input>>(
|
||||
* [](const double _x) -> double {
|
||||
* return _x * _x;
|
||||
* }
|
||||
* );
|
||||
* \endcode
|
||||
*
|
||||
* what we have defined here is a node which takes a single input tagged keys::Input and produces an output tagged as keys::Output. The input key
|
||||
* declares an edge from that source to this node. Other nodes can use keys::Output as an input, and a successfully prepared result can be read with get
|
||||
* or retained with share. These connections allow us to form a larger graph. Consider now the following code
|
||||
*
|
||||
* \code{.cpp}
|
||||
* using namespace serif::utils::dependencies;
|
||||
*
|
||||
* struct tags {
|
||||
* struct X{};
|
||||
* struct Y{};
|
||||
*
|
||||
* struct Output1{};
|
||||
* struct Output2{};
|
||||
* };
|
||||
*
|
||||
* double x{1.0};
|
||||
* double y{2.0};
|
||||
* auto context = make_dependency_context(borrow<tags::X>(x), borrow<tags::Y>(y));
|
||||
*
|
||||
* auto func1 = make_computation<tags::Output1, InputList<tags::X>>(
|
||||
* [](const double _x) -> double {
|
||||
* return _x * _x;
|
||||
* }
|
||||
* );
|
||||
*
|
||||
* auto func2 = make_computation<tags::Output2, InputList<tags::Output1, tags::Y>>(
|
||||
* [](const double _x, const double _y) -> double {
|
||||
* return _x + _y;
|
||||
* }
|
||||
* );
|
||||
* \endcode
|
||||
*
|
||||
* we have now defined two computations, the first takes a single input, whichever input is tagged as tags::X and produces an output tagged as tags::Output1.
|
||||
* The second computation takes two inputs, the first is the output of the first computation (lookup by the tag) and the second is whichever input is tagged as tags::Y,
|
||||
* and produces an output tagged as tags::Output2. Note that the order of arguments to the lambda must be the same as the order of the tags in the InputList, the
|
||||
* dependencies module will automatically order arguments based on the InputList. There is type checking to ensure that the types match, but if you have two
|
||||
* inputs of the same type that are flipped there is no way for the system to inspect that and warn you. Further, depending on your compiler and flags
|
||||
* there may be implicit casting between convertable types (e.g. int <-> double).
|
||||
*
|
||||
* @todo We may want to remove the ability to have implicit casting between types, this is a bit of a footgun and may lead to subtle bugs.
|
||||
*
|
||||
* @note This is primary goal of this system, split computations into small pieces with the ability to cache intermediate results and reuse
|
||||
* as many of those intermediate results as we can can prove is safe to do. The whole reason this system is so complex is that very purpose. There is a fundamental
|
||||
* problem with high dimensional stellar models, they are always going to be slow, therefore we need to accept some degree of complexity in order to make them tractable.
|
||||
*
|
||||
* **Computation Cache**
|
||||
* Once we have the computations we need to connect them together, generally use the helper function make_computation_cache which
|
||||
* takes a context and a variadic pack of computations. There are *a lot* of compile time checks which run to ensure that the graph is valid, the context
|
||||
* describes what is needed to compute the graph, and that each computation is valid. Generally if a static analysis error or compile error pops up here
|
||||
* it probably means there is a mistake in the definition of the context or the computations. I've tried to make these errors as clear as possible; however,
|
||||
* there is only so much that you can do with the C++ compiler. I recommend first checking, probably with a drawn-out diagram, that all the
|
||||
* tags are correct and what you expect.
|
||||
*
|
||||
* A developer can also choose to use the make_computation_graph function to explicitly name a graph. This
|
||||
* is useful when naming or reusing a collection of computations (make_computation_cache
|
||||
* calls make_computation_graph and then fixes the source types from the supplied context without retaining the context). Now consider the following code assuming the context and computations from above:
|
||||
*
|
||||
* \code{.cpp}
|
||||
* auto graph_cache = make_computation_cache(context, func1, func2);
|
||||
* \endcode
|
||||
*
|
||||
* At this point we have a cache which we can access various parts of; however, no computations have been run yet. To run a computation we must call try_prepare.
|
||||
*
|
||||
* try_prepare returns std::expected: ordinary rejection is a value, and no candidate results or snapshots are published on failure.
|
||||
* Exceptions from a callable or snapshot policy propagate with the same cache protection. Previously retained results remain valid,
|
||||
* but get and share require a successful current preparation. Always check the report before reading results.
|
||||
*
|
||||
* try_prepare takes the context and evaluates the requested nodes and their prerequisites, reusing unchanged results. By default every result
|
||||
* is requested, so the first call evaluates the entire graph. RequestedResults can restrict evaluation to a branch.
|
||||
*
|
||||
* \code{.cpp}
|
||||
* auto report = graph_cache.try_prepare(context);
|
||||
* if (!report) {
|
||||
* return; // Handle report.error() before reading the cache.
|
||||
* }
|
||||
*
|
||||
* double func1_result = graph_cache.template get<tags::Output1>();
|
||||
* double func2_result = graph_cache.template get<tags::Output2>();
|
||||
*
|
||||
* auto func1_status = report->template status<tags::Output1>();
|
||||
* auto func2_status = report->template status<tags::Output2>();
|
||||
*
|
||||
* std::string_view func1_status_string = preparation_status_name_map[func1_status]; // note this is an inline map defined in serif::utils::dependencies
|
||||
* std::string_view func2_status_string = preparation_status_name_map[func2_status];
|
||||
*
|
||||
* std::println("Func1 Result: {}, Func2 Result: {}, Func1 Status: {}, Func2 Status: {}", func1_result, func2_result, func1_status_string, func2_status_string);
|
||||
* \endcode
|
||||
*
|
||||
* Now what we could do is change only one part of the input context and call try_prepare again. Lets say we change only y, such that only func2 is stale.
|
||||
*
|
||||
* \code{.cpp}
|
||||
* y = 3.0;
|
||||
* report = graph_cache.try_prepare(context);
|
||||
* if (!report) {
|
||||
* return; // Handle report.error() before reading the cache.
|
||||
* }
|
||||
*
|
||||
* double func1_result = graph_cache.template get<tags::Output1>();
|
||||
* double func2_result = graph_cache.template get<tags::Output2>();
|
||||
*
|
||||
* auto func1_status = report->template status<tags::Output1>();
|
||||
* auto func2_status = report->template status<tags::Output2>();
|
||||
*
|
||||
* std::string_view func1_status_string = preparation_status_name_map[func1_status]; // note this is an inline map defined in serif::utils::dependencies
|
||||
* std::string_view func2_status_string = preparation_status_name_map[func2_status];
|
||||
*
|
||||
* std::println("Func1 Result: {}, Func2 Result: {}, Func1 Status: {}, Func2 Status: {}", func1_result, func2_result, func1_status_string, func2_status_string);
|
||||
* \endcode
|
||||
*/
|
||||
namespace serif::utils::dependencies{};
|
||||
124
src/include/serif/utils/dependencies/dependency_context.hpp
Normal file
124
src/include/serif/utils/dependencies/dependency_context.hpp
Normal file
@@ -0,0 +1,124 @@
|
||||
#pragma once
|
||||
|
||||
#include <concepts>
|
||||
#include <tuple>
|
||||
#include <type_traits>
|
||||
#include <utility>
|
||||
#include "serif/utils/dependencies/resource.hpp"
|
||||
#include "serif/utils/dependencies/detail/type_lists.hpp"
|
||||
#include "serif/utils/misc/concepts/concat.hpp"
|
||||
|
||||
namespace serif::utils::dependencies {
|
||||
template <typename ParentT, IsKeyList KeysT>
|
||||
requires SatisfiesKeyList<typename ParentT::Bindings, KeysT>
|
||||
class DependencyContextView;
|
||||
|
||||
template <IsResource... ResourceTs>
|
||||
requires misc::traits::all_unique_types_v<typename ResourceTs::Key...>
|
||||
class DependencyContext {
|
||||
public:
|
||||
using Resources = types::TypeList<ResourceTs...>;
|
||||
using Keys = KeyList<typename ResourceTs::Key...>;
|
||||
using Bindings = dependencies::Bindings<Binding<typename ResourceTs::Key, typename ResourceTs::Target>...>;
|
||||
using ResourceBindings = dependencies::Bindings<Binding<typename ResourceTs::Key, ResourceTs>...>;
|
||||
|
||||
/// Each resource explicitly chooses owned or borrowed storage.
|
||||
explicit(sizeof...(ResourceTs) != 0) DependencyContext(ResourceTs... resources) : m_resources(std::move(resources)...) {}
|
||||
|
||||
/// Retrieve a resource by role even when several roles have the same target type.
|
||||
template <typename KeyT> requires BindingsWithBinding<Bindings, KeyT>
|
||||
[[nodiscard]] const auto& get() const {
|
||||
return resource<KeyT>().get();
|
||||
}
|
||||
|
||||
/// Access the value together with the policy used to track it.
|
||||
template <typename KeyT> requires BindingsWithBinding<Bindings, KeyT>
|
||||
[[nodiscard]] const auto& resource() const {
|
||||
constexpr std::size_t index = detail::type_index<KeyT>(types::TypeList<typename ResourceTs::Key...>{});
|
||||
return std::get<index>(m_resources);
|
||||
}
|
||||
|
||||
/// Select declared roles without copying their objects or tracking policies.
|
||||
template <IsKeyList KeysT> requires SatisfiesKeyList<Bindings, KeysT>
|
||||
[[nodiscard]] auto select(KeysT) const & {
|
||||
return DependencyContextView<DependencyContext, std::remove_cvref_t<KeysT>>(*this);
|
||||
}
|
||||
|
||||
/// A view cannot safely borrow a temporary context.
|
||||
template <IsKeyList KeysT>
|
||||
auto select(KeysT) const && = delete;
|
||||
|
||||
private:
|
||||
std::tuple<ResourceTs...> m_resources;
|
||||
};
|
||||
|
||||
template <typename ParentT, typename... KeyTs>
|
||||
requires SatisfiesKeyList<typename ParentT::Bindings, KeyList<KeyTs...>>
|
||||
class DependencyContextView<ParentT, KeyList<KeyTs...>> {
|
||||
public:
|
||||
using Keys = KeyList<KeyTs...>;
|
||||
using Bindings = dependencies::Bindings<Binding<KeyTs, bound_type_t<typename ParentT::Bindings, KeyTs>>...>;
|
||||
using ResourceBindings = dependencies::Bindings<Binding<KeyTs, bound_type_t<typename ParentT::ResourceBindings, KeyTs>>...>;
|
||||
using Resources = types::TypeList<bound_type_t<typename ParentT::ResourceBindings, KeyTs>...>;
|
||||
|
||||
/// The parent context must outlive this view.
|
||||
explicit DependencyContextView(const ParentT& parent) : m_parent(parent) {}
|
||||
|
||||
/// Reject temporary parents even when constructing a view directly.
|
||||
DependencyContextView(const ParentT&&) = delete;
|
||||
|
||||
/// Expose only the declared portion of the parent context.
|
||||
template <typename KeyT> requires BindingsWithBinding<Bindings, KeyT>
|
||||
[[nodiscard]] const auto& get() const {
|
||||
return m_parent.template get<KeyT>();
|
||||
}
|
||||
|
||||
/// Preserve the original source identity and tracking policy through selection.
|
||||
template <typename KeyT> requires BindingsWithBinding<Bindings, KeyT>
|
||||
[[nodiscard]] const auto& resource() const {
|
||||
return m_parent.template resource<KeyT>();
|
||||
}
|
||||
|
||||
private:
|
||||
const ParentT& m_parent;
|
||||
};
|
||||
|
||||
template <typename T>
|
||||
inline constexpr bool is_dependency_context_v = false;
|
||||
|
||||
template <typename... Resources>
|
||||
inline constexpr bool is_dependency_context_v<DependencyContext<Resources...>> = true;
|
||||
|
||||
template <typename ParentT, typename KeysT>
|
||||
inline constexpr bool is_dependency_context_v<DependencyContextView<ParentT, KeysT>> = true;
|
||||
|
||||
template <typename T>
|
||||
concept IsDependencyContext = is_dependency_context_v<std::remove_cvref_t<T>>;
|
||||
|
||||
/// Build a context whose schema follows directly from its resource keys and types.
|
||||
template <typename... Resources>
|
||||
requires (IsResource<Resources> && ...) && misc::traits::all_unique_types_v<typename std::remove_cvref_t<Resources>::Key...>
|
||||
[[nodiscard]] auto make_dependency_context(Resources&&... resources) {
|
||||
return DependencyContext<std::remove_cvref_t<Resources>...>(std::forward<Resources>(resources)...);
|
||||
}
|
||||
|
||||
namespace detail {
|
||||
/** @brief Combining contexts must preserve every resource, with exactly one owner of each key. */
|
||||
template <typename... Resources>
|
||||
[[nodiscard]] consteval bool combinable_resources(types::TypeList<Resources...>) {
|
||||
return (std::copy_constructible<Resources> && ...) && misc::traits::all_unique_types_v<typename Resources::Key...>;
|
||||
}
|
||||
}
|
||||
|
||||
/** @brief Copy disjoint resources and their tracking policies into one context; borrowed objects retain their original lifetime. */
|
||||
template <IsDependencyContext... Contexts>
|
||||
requires (detail::combinable_resources(misc::concepts::ConcatT<typename Contexts::Resources...>{}))
|
||||
[[nodiscard]] auto combine_dependency_contexts(const Contexts&... contexts) {
|
||||
const auto resources = []<typename... Resources>(const auto& context, types::TypeList<Resources...>) {
|
||||
return std::tie(context.template resource<typename Resources::Key>()...);
|
||||
};
|
||||
return std::apply([](const auto&... entries) {
|
||||
return make_dependency_context(entries...);
|
||||
}, std::tuple_cat(resources(contexts, typename Contexts::Resources{})...));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
#pragma once
|
||||
|
||||
#include <array>
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <memory>
|
||||
#include <tuple>
|
||||
#include <utility>
|
||||
#include "serif/utils/dependencies/detail/cache_traits.hpp"
|
||||
|
||||
namespace serif::utils::dependencies::detail {
|
||||
template <typename ResourceT>
|
||||
struct SourceSnapshot {
|
||||
using Captured = decltype(std::declval<const typename ResourceT::Policy&>().capture(std::declval<const typename ResourceT::Target&>()));
|
||||
const void* identity{nullptr};
|
||||
std::shared_ptr<const Captured> captured;
|
||||
};
|
||||
|
||||
template <typename ValueT, std::size_t InputCount>
|
||||
struct CachedResult {
|
||||
std::shared_ptr<const ValueT> value;
|
||||
std::array<std::uint64_t, InputCount> input_versions{};
|
||||
};
|
||||
|
||||
template <typename GraphT, typename BindingsT, typename ResourcesT, typename IndicesT>
|
||||
struct ComputationCacheStorage;
|
||||
|
||||
// Shared immutable objects make candidate preparation cheap to stage and safe to discard on failure.
|
||||
template <typename GraphT, typename BindingsT, typename... Resources, std::size_t... Indices>
|
||||
struct ComputationCacheStorage<GraphT, BindingsT, types::TypeList<Resources...>, std::index_sequence<Indices...>> {
|
||||
std::tuple<SourceSnapshot<Resources>...> sources;
|
||||
std::tuple<CachedResult<bound_type_t<BindingsT, typename GraphT::template ComputationAt<Indices>::Key>,
|
||||
KeyListSize<typename GraphT::template ComputationAt<Indices>::Inputs>::value>...> results;
|
||||
std::array<std::uint64_t, sizeof...(Resources) + sizeof...(Indices)> versions{};
|
||||
};
|
||||
}
|
||||
64
src/include/serif/utils/dependencies/detail/cache_traits.hpp
Normal file
64
src/include/serif/utils/dependencies/detail/cache_traits.hpp
Normal file
@@ -0,0 +1,64 @@
|
||||
#pragma once
|
||||
|
||||
#include <concepts>
|
||||
#include <cstddef>
|
||||
#include <type_traits>
|
||||
#include <utility>
|
||||
#include <variant>
|
||||
#include "serif/utils/dependencies/detail/schema.hpp"
|
||||
#include "serif/utils/misc/concepts/filter.hpp"
|
||||
|
||||
namespace serif::utils::dependencies::detail {
|
||||
template <typename GraphT>
|
||||
struct UsedResource {
|
||||
template <typename ResourceT>
|
||||
struct Predicate : std::bool_constant<contains_key_v<typename ResourceT::Key, typename GraphT::Inputs>> {};
|
||||
};
|
||||
|
||||
template <typename GraphT, typename ContextT>
|
||||
using UsedResources = misc::concepts::FilterT<typename ContextT::Resources, UsedResource<GraphT>::template Predicate>;
|
||||
|
||||
template <typename ResourcesT>
|
||||
struct ResourceKeys;
|
||||
|
||||
template <typename... Resources>
|
||||
struct ResourceKeys<types::TypeList<Resources...>> {
|
||||
using type = KeyList<typename Resources::Key...>;
|
||||
static constexpr bool trackable = (SnapshotPolicyFor<typename Resources::Policy, typename Resources::Target> && ...);
|
||||
};
|
||||
|
||||
/// Ordinary graph failures retain an exact type; numerical conversions must not erase diagnostics.
|
||||
template <typename Source, typename Target>
|
||||
inline constexpr bool compatible_error_v = std::same_as<Source, Target>;
|
||||
|
||||
/// Independent modules may explicitly aggregate their unchanged failures in an unambiguous tagged union.
|
||||
template <typename Source, typename... Alternatives>
|
||||
inline constexpr bool compatible_error_v<Source, std::variant<Alternatives...>> =
|
||||
std::same_as<Source, std::variant<Alternatives...>> || ((std::same_as<Source, Alternatives> || ...) && std::constructible_from<std::variant<Alternatives...>, Source>);
|
||||
|
||||
/// Value-returning calculations can share a graph with expected results carrying its declared error or an exact union alternative.
|
||||
template <typename GraphT, typename BindingsT, typename ErrorT, std::size_t... Indices>
|
||||
[[nodiscard]] consteval bool compatible_errors(std::index_sequence<Indices...>) {
|
||||
return ((std::is_void_v<typename ResolveComputation<typename GraphT::template ComputationAt<Indices>, BindingsT>::Error> ||
|
||||
compatible_error_v<typename ResolveComputation<typename GraphT::template ComputationAt<Indices>, BindingsT>::Error, ErrorT>) && ...);
|
||||
}
|
||||
|
||||
template <typename GraphT, typename ContextT, typename ErrorT, bool Valid = ComputationGraphFor<GraphT, ContextT>>
|
||||
struct ComputationCacheTraits {
|
||||
static constexpr bool valid = false;
|
||||
};
|
||||
|
||||
template <typename GraphT, typename ContextT, typename ErrorT>
|
||||
struct ComputationCacheTraits<GraphT, ContextT, ErrorT, true> {
|
||||
using Bindings = typename ComputationGraphSchema<GraphT, ContextT>::ResolvedBindings;
|
||||
using Resources = UsedResources<GraphT, ContextT>;
|
||||
using Sources = typename ResourceKeys<Resources>::type;
|
||||
static constexpr bool valid = ResourceKeys<Resources>::trackable && std::is_object_v<ErrorT> && std::move_constructible<ErrorT> &&
|
||||
compatible_errors<GraphT, Bindings, ErrorT>(std::make_index_sequence<GraphT::size>{});
|
||||
};
|
||||
}
|
||||
|
||||
namespace serif::utils::dependencies {
|
||||
template <typename GraphT, typename ContextT, typename ErrorT>
|
||||
concept CanPrepare = detail::ComputationCacheTraits<std::remove_cvref_t<GraphT>, std::remove_cvref_t<ContextT>, ErrorT>::valid;
|
||||
}
|
||||
97
src/include/serif/utils/dependencies/detail/graph.hpp
Normal file
97
src/include/serif/utils/dependencies/detail/graph.hpp
Normal file
@@ -0,0 +1,97 @@
|
||||
#pragma once
|
||||
|
||||
#include <array>
|
||||
#include <cstddef>
|
||||
#include "serif/utils/dependencies/detail/type_lists.hpp"
|
||||
|
||||
namespace serif::utils::dependencies::detail {
|
||||
/// Immutable topology of computed nodes; context sources are resolved separately.
|
||||
template <typename... Computations>
|
||||
struct Graph {
|
||||
static constexpr std::size_t size = sizeof...(Computations);
|
||||
using Mask = std::array<bool, size>;
|
||||
using Matrix = std::array<Mask, size>;
|
||||
using Order = std::array<std::size_t, size>;
|
||||
|
||||
/// Record only computed prerequisites; other input keys are supplied by a context.
|
||||
template <typename ComputationT>
|
||||
[[nodiscard]] static consteval Mask prerequisites() {
|
||||
return {contains_key_v<typename Computations::Key, typename ComputationT::Inputs>...};
|
||||
}
|
||||
|
||||
/// edges[consumer][producer] is true when the consumer reads the producer's result.
|
||||
static constexpr Matrix edges{prerequisites<Computations>()...};
|
||||
|
||||
/**
|
||||
* @brief Order prerequisites before their users, preferring the earliest ready declaration at each step.
|
||||
* @note Remaining prerequisite counts avoid rescanning each candidate's entire input list. Both the initial count
|
||||
* and subsequent updates are quadratic in graph size, keeping complete stellar graphs within constexpr limits.
|
||||
*/
|
||||
[[nodiscard]] static consteval Order make_order() {
|
||||
Order result{};
|
||||
result.fill(size);
|
||||
Mask visited{};
|
||||
std::array<std::size_t, size> remaining{};
|
||||
for (std::size_t candidate = 0; candidate < size; ++candidate) {
|
||||
std::size_t count = 0;
|
||||
for (const bool prerequisite : edges[candidate]) {
|
||||
count += prerequisite;
|
||||
}
|
||||
remaining[candidate] = count;
|
||||
}
|
||||
for (std::size_t position = 0; position < size; ++position) {
|
||||
std::size_t selected = size;
|
||||
for (std::size_t candidate = 0; candidate < size; ++candidate) {
|
||||
if (!visited[candidate] && remaining[candidate] == 0) {
|
||||
selected = candidate;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (selected == size) {
|
||||
break;
|
||||
}
|
||||
result[position] = selected;
|
||||
visited[selected] = true;
|
||||
for (std::size_t user = 0; user < size; ++user) {
|
||||
if (edges[user][selected]) {
|
||||
--remaining[user];
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/// Each execution position maps to a declaration index; size marks an unresolved cycle.
|
||||
static constexpr Order order = make_order();
|
||||
|
||||
/// A missing position identifies a cycle, including a computation requiring itself.
|
||||
[[nodiscard]] static consteval bool acyclic() {
|
||||
for (const std::size_t index : order) {
|
||||
if (index == size) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Expand requested results to include all computed prerequisites in one reverse traversal.
|
||||
* @note Every user appears after its prerequisites in the topological order. Visiting users first
|
||||
* therefore marks their inputs before those inputs are visited, independently of declaration order.
|
||||
*/
|
||||
template <typename... Keys>
|
||||
[[nodiscard]] static consteval Mask required(KeyList<Keys...>) {
|
||||
Mask result{(contains_key_v<typename Computations::Key, KeyList<Keys...>>)...};
|
||||
for (std::size_t position = size; position > 0; --position) {
|
||||
const std::size_t user = order[position - 1];
|
||||
if (user == size || !result[user]) {
|
||||
continue;
|
||||
}
|
||||
for (std::size_t input = 0; input < size; ++input) {
|
||||
result[input] = result[input] || edges[user][input];
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
};
|
||||
}
|
||||
48
src/include/serif/utils/dependencies/detail/result.hpp
Normal file
48
src/include/serif/utils/dependencies/detail/result.hpp
Normal file
@@ -0,0 +1,48 @@
|
||||
#pragma once
|
||||
|
||||
#include <concepts>
|
||||
#include <expected>
|
||||
#include <memory>
|
||||
#include <type_traits>
|
||||
#include <utility>
|
||||
#include "serif/utils/types/type_list.hpp"
|
||||
|
||||
namespace serif::utils::dependencies::detail {
|
||||
/// Computations publish owned, movable values, either directly or through std::expected.
|
||||
template <typename T>
|
||||
struct Result {
|
||||
using Value = T;
|
||||
using Error = void;
|
||||
static constexpr bool fallible = false;
|
||||
static constexpr bool valid = std::is_object_v<T> && std::move_constructible<T>;
|
||||
};
|
||||
|
||||
template <typename ValueT, typename ErrorT>
|
||||
struct Result<std::expected<ValueT, ErrorT>> {
|
||||
using Value = ValueT;
|
||||
using Error = ErrorT;
|
||||
static constexpr bool fallible = true;
|
||||
static constexpr bool valid = std::is_object_v<ValueT> && std::move_constructible<ValueT>;
|
||||
};
|
||||
|
||||
/** @brief Owning computations receive the cache handle; ordinary computations receive the value. */
|
||||
template <typename ComputationT, typename TargetT>
|
||||
using ComputationArgument = std::conditional_t<ComputationT::owns_inputs, std::shared_ptr<const TargetT>, TargetT>;
|
||||
|
||||
/// Match the const argument access used by the runtime cache without constructing any values.
|
||||
template <typename ComputationT, typename... Arguments>
|
||||
using InvocationResult = decltype(std::declval<const ComputationT&>().evaluate(
|
||||
std::declval<const ComputationArgument<ComputationT, Arguments>&>()...
|
||||
));
|
||||
|
||||
/// Ill-formed calls fail the graph concept instead of producing a hard instantiation error.
|
||||
template <typename ComputationT, typename ArgumentsT, typename = void>
|
||||
struct Invocation {
|
||||
static constexpr bool valid = false;
|
||||
};
|
||||
|
||||
template <typename ComputationT, typename... Arguments>
|
||||
struct Invocation<ComputationT, types::TypeList<Arguments...>, std::void_t<InvocationResult<ComputationT, Arguments...>>> :
|
||||
Result<std::remove_cv_t<InvocationResult<ComputationT, Arguments...>>> {
|
||||
};
|
||||
}
|
||||
73
src/include/serif/utils/dependencies/detail/schema.hpp
Normal file
73
src/include/serif/utils/dependencies/detail/schema.hpp
Normal file
@@ -0,0 +1,73 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstddef>
|
||||
#include <type_traits>
|
||||
#include "serif/utils/dependencies/dependency_context.hpp"
|
||||
#include "serif/utils/dependencies/computation_graph.hpp"
|
||||
#include "serif/utils/dependencies/detail/result.hpp"
|
||||
|
||||
namespace serif::utils::dependencies::detail {
|
||||
template <typename BindingsT, typename BindingT>
|
||||
struct AppendBinding;
|
||||
|
||||
template <typename... BindingsTs, typename BindingT>
|
||||
struct AppendBinding<Bindings<BindingsTs...>, BindingT> {
|
||||
using type = Bindings<BindingsTs..., BindingT>;
|
||||
};
|
||||
|
||||
/// Missing inputs stop resolution before instantiating the callable or its result type.
|
||||
template <typename ComputationT, typename BindingsT, bool Available = SatisfiesKeyList<BindingsT, typename ComputationT::Inputs>>
|
||||
struct ResolveComputation {
|
||||
static constexpr bool valid = false;
|
||||
};
|
||||
|
||||
template <typename ComputationT, typename BindingsT>
|
||||
struct ResolveComputation<ComputationT, BindingsT, true> : Invocation<ComputationT, ResolvedTargets<BindingsT, typename ComputationT::Inputs>> {
|
||||
};
|
||||
|
||||
/// Walk execution positions, appending each result type to the available input bindings.
|
||||
template <typename GraphT, typename BindingsT, std::size_t Position, bool Done = (Position == GraphT::size)>
|
||||
struct ResolveGraph;
|
||||
|
||||
/// An invalid callable must not instantiate the remaining graph or access a missing Value type.
|
||||
template <typename GraphT, typename BindingsT, std::size_t Position, typename ComputationT, bool Valid>
|
||||
struct ResolveStep {
|
||||
static constexpr bool valid = false;
|
||||
};
|
||||
|
||||
template <typename GraphT, typename BindingsT, std::size_t Position, typename ComputationT>
|
||||
struct ResolveStep<GraphT, BindingsT, Position, ComputationT, true> : ResolveGraph<GraphT,
|
||||
typename AppendBinding<BindingsT, Binding<typename ComputationT::Key, typename ResolveComputation<ComputationT, BindingsT>::Value>>::type,
|
||||
Position + 1> {};
|
||||
|
||||
template <typename GraphT, typename BindingsT, std::size_t Position>
|
||||
struct ResolveGraph<GraphT, BindingsT, Position, false> : ResolveStep<GraphT, BindingsT, Position,
|
||||
typename GraphT::template ComputationAt<GraphT::Dependencies::order[Position]>,
|
||||
ResolveComputation<typename GraphT::template ComputationAt<GraphT::Dependencies::order[Position]>, BindingsT>::valid> {};
|
||||
|
||||
template <typename GraphT, typename BindingsT, std::size_t Position>
|
||||
struct ResolveGraph<GraphT, BindingsT, Position, true> {
|
||||
static constexpr bool valid = true;
|
||||
using ResolvedBindings = BindingsT;
|
||||
};
|
||||
|
||||
/// A key has exactly one producer: either the context or a computation.
|
||||
template <typename BindingsT, typename... Keys>
|
||||
[[nodiscard]] consteval bool independent_results(KeyList<Keys...>) {
|
||||
return (!has_binding_v<BindingsT, Keys> && ...);
|
||||
}
|
||||
|
||||
template <typename GraphT, typename ContextT, bool Independent = independent_results<typename ContextT::Bindings>(typename GraphT::Results{})>
|
||||
struct ComputationGraphSchema {
|
||||
static constexpr bool valid = false;
|
||||
};
|
||||
|
||||
template <typename GraphT, typename ContextT>
|
||||
struct ComputationGraphSchema<GraphT, ContextT, true> : ResolveGraph<GraphT, typename ContextT::Bindings, 0> {};
|
||||
}
|
||||
|
||||
namespace serif::utils::dependencies {
|
||||
template <typename GraphT, typename ContextT>
|
||||
concept ComputationGraphFor = IsComputationGraph<GraphT> && IsDependencyContext<ContextT> &&
|
||||
detail::ComputationGraphSchema<std::remove_cvref_t<GraphT>, std::remove_cvref_t<ContextT>>::valid;
|
||||
}
|
||||
67
src/include/serif/utils/dependencies/detail/type_lists.hpp
Normal file
67
src/include/serif/utils/dependencies/detail/type_lists.hpp
Normal file
@@ -0,0 +1,67 @@
|
||||
#pragma once
|
||||
|
||||
#include <array>
|
||||
#include <cstddef>
|
||||
#include <type_traits>
|
||||
#include "serif/utils/dependencies/key_list.hpp"
|
||||
|
||||
namespace serif::utils::dependencies::detail {
|
||||
/// Locate a type without requiring the types themselves to be constructible.
|
||||
template <typename KeyT, typename... Types>
|
||||
[[nodiscard]] consteval std::size_t type_index(types::TypeList<Types...>) {
|
||||
constexpr std::array<bool, sizeof...(Types)> matches{std::is_same_v<KeyT, Types>...};
|
||||
for (std::size_t index = 0; index < matches.size(); ++index) {
|
||||
if (matches[index]) { return index; }
|
||||
}
|
||||
return matches.size();
|
||||
}
|
||||
|
||||
template <typename KeyT, typename KeysT>
|
||||
inline constexpr bool contains_key_v = false;
|
||||
|
||||
template <typename KeyT, typename... Keys>
|
||||
inline constexpr bool contains_key_v<KeyT, KeyList<Keys...>> = type_index<KeyT>(types::TypeList<Keys...>{}) < sizeof...(Keys);
|
||||
|
||||
template <typename RequiredT, typename AvailableT>
|
||||
inline constexpr bool key_list_subset_v = false;
|
||||
|
||||
template <typename... Keys, typename AvailableT>
|
||||
inline constexpr bool key_list_subset_v<KeyList<Keys...>, AvailableT> = [] {
|
||||
constexpr std::array<bool, sizeof...(Keys)> available{contains_key_v<Keys, AvailableT>...};
|
||||
for (const bool present : available) {
|
||||
if (!present) { return false; }
|
||||
}
|
||||
return true;
|
||||
}();
|
||||
|
||||
template <typename KeysT>
|
||||
struct KeyListSize;
|
||||
|
||||
template <typename... Keys>
|
||||
struct KeyListSize<KeyList<Keys...>> : std::integral_constant<std::size_t, sizeof...(Keys)> {};
|
||||
|
||||
template <typename ListT, typename T>
|
||||
struct AppendUnique;
|
||||
|
||||
template <typename... Keys, typename T>
|
||||
struct AppendUnique<KeyList<Keys...>, T> {
|
||||
using type = std::conditional_t<contains_key_v<T, KeyList<Keys...>>, KeyList<Keys...>, KeyList<Keys..., T>>;
|
||||
};
|
||||
|
||||
template <typename ResultT, typename... Keys>
|
||||
struct AppendKeys {
|
||||
using type = ResultT;
|
||||
};
|
||||
|
||||
template <typename ResultT, typename FirstT, typename... Rest>
|
||||
struct AppendKeys<ResultT, FirstT, Rest...> : AppendKeys<typename AppendUnique<ResultT, FirstT>::type, Rest...> {};
|
||||
|
||||
template <typename ResultT, typename... Lists>
|
||||
struct MergeKeyLists {
|
||||
using type = ResultT;
|
||||
};
|
||||
|
||||
template <typename ResultT, typename... Keys, typename... Rest>
|
||||
struct MergeKeyLists<ResultT, KeyList<Keys...>, Rest...> :
|
||||
MergeKeyLists<typename AppendKeys<ResultT, Keys...>::type, Rest...> {};
|
||||
}
|
||||
53
src/include/serif/utils/dependencies/key_list.hpp
Normal file
53
src/include/serif/utils/dependencies/key_list.hpp
Normal file
@@ -0,0 +1,53 @@
|
||||
#pragma once
|
||||
|
||||
#include <type_traits>
|
||||
#include "serif/utils/dependencies/binding.hpp"
|
||||
#include "serif/utils/misc/traits/type_uniqueness.hpp"
|
||||
#include "serif/utils/types/type_list.hpp"
|
||||
|
||||
namespace serif::utils::dependencies {
|
||||
/// An ordered collection of role keys, independent of their associated value types.
|
||||
template <typename... KeyTs>
|
||||
struct KeyList {
|
||||
using Keys = types::TypeList<KeyTs...>;
|
||||
};
|
||||
|
||||
/// Computation arguments are supplied in this key order.
|
||||
template <typename... KeyTs>
|
||||
using InputList = KeyList<KeyTs...>;
|
||||
|
||||
/// Preparing these results also prepares their required intermediate computations.
|
||||
template <typename... KeyTs>
|
||||
using RequestedResults = KeyList<KeyTs...>;
|
||||
|
||||
template <typename T>
|
||||
inline constexpr bool is_key_list_v = false;
|
||||
|
||||
template <typename... KeyTs>
|
||||
inline constexpr bool is_key_list_v<KeyList<KeyTs...>> = misc::traits::all_unique_types_v<KeyTs...>;
|
||||
|
||||
template <typename T>
|
||||
concept IsKeyList = is_key_list_v<std::remove_cvref_t<T>>;
|
||||
|
||||
template <typename BindingsT, typename KeysT>
|
||||
struct KeyListBindings;
|
||||
|
||||
template <typename BindingsT, typename... KeyTs>
|
||||
struct KeyListBindings<BindingsT, KeyList<KeyTs...>> :
|
||||
std::bool_constant<(BindingsWithBinding<BindingsT, KeyTs> && ...)> {};
|
||||
|
||||
template <typename BindingsT, typename KeysT>
|
||||
struct ResolveKeys;
|
||||
|
||||
template <typename BindingsT, typename... KeyTs>
|
||||
struct ResolveKeys<BindingsT, KeyList<KeyTs...>> {
|
||||
using type = types::TypeList<bound_type_t<BindingsT, KeyTs>...>;
|
||||
};
|
||||
|
||||
template <typename BindingsT, typename KeysT>
|
||||
concept SatisfiesKeyList = IsBindings<BindingsT> && IsKeyList<KeysT> && KeyListBindings<BindingsT, std::remove_cvref_t<KeysT>>::value;
|
||||
|
||||
template <typename BindingsT, typename KeysT>
|
||||
requires SatisfiesKeyList<BindingsT, KeysT>
|
||||
using ResolvedTargets = ResolveKeys<BindingsT, std::remove_cvref_t<KeysT>>::type;
|
||||
}
|
||||
26
src/include/serif/utils/dependencies/key_list_operations.hpp
Normal file
26
src/include/serif/utils/dependencies/key_list_operations.hpp
Normal file
@@ -0,0 +1,26 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstddef>
|
||||
#include <type_traits>
|
||||
#include "serif/utils/dependencies/detail/type_lists.hpp"
|
||||
|
||||
namespace serif::utils::dependencies {
|
||||
/** @brief Test exact key membership, rejecting malformed and duplicate-key lists. */
|
||||
template <typename KeyT, typename KeysT>
|
||||
concept ContainsKey = IsKeyList<KeysT> && detail::contains_key_v<KeyT, std::remove_cvref_t<KeysT>>;
|
||||
|
||||
/** @brief Count distinct declared keys without constructing their associated values. */
|
||||
template <IsKeyList KeysT>
|
||||
inline constexpr std::size_t key_count_v = detail::KeyListSize<std::remove_cvref_t<KeysT>>::value;
|
||||
|
||||
/** @brief Locate a declared key in its ordered list; absent keys are rejected at compile time. */
|
||||
template <typename KeyT, typename KeysT>
|
||||
requires ContainsKey<KeyT, KeysT>
|
||||
[[nodiscard]] consteval std::size_t key_index() {
|
||||
return detail::type_index<KeyT>(typename std::remove_cvref_t<KeysT>::Keys{});
|
||||
}
|
||||
|
||||
/// Combine requirements in first-appearance order, retaining distinct keys with shared targets.
|
||||
template <IsKeyList... Lists>
|
||||
using CombinedKeyList = detail::MergeKeyLists<KeyList<>, std::remove_cvref_t<Lists>...>::type;
|
||||
}
|
||||
17
src/include/serif/utils/dependencies/keys.hpp
Normal file
17
src/include/serif/utils/dependencies/keys.hpp
Normal file
@@ -0,0 +1,17 @@
|
||||
#pragma once
|
||||
|
||||
namespace serif::utils::dependencies::keys {
|
||||
// Optional roles for small computations. Use distinct application keys or existing term
|
||||
// descriptors when a graph needs more specific names; no registration is required.
|
||||
|
||||
/// The main input to a computation.
|
||||
struct Input {};
|
||||
/// The main result of a computation.
|
||||
struct Output {};
|
||||
/// A multiplicative scale supplied as an input.
|
||||
struct Scale {};
|
||||
/// Configuration values that can affect the result.
|
||||
struct Parameters {};
|
||||
/// Positions supplied to a computation, in the caller's coordinate convention.
|
||||
struct Coordinates {};
|
||||
}
|
||||
282
src/include/serif/utils/dependencies/observers.hpp
Normal file
282
src/include/serif/utils/dependencies/observers.hpp
Normal file
@@ -0,0 +1,282 @@
|
||||
#pragma once
|
||||
|
||||
#include <array>
|
||||
#include <concepts>
|
||||
#include <cstddef>
|
||||
#include <exception>
|
||||
#include <functional>
|
||||
#include <tuple>
|
||||
#include <type_traits>
|
||||
#include <utility>
|
||||
#include <format>
|
||||
#include <string_view>
|
||||
|
||||
#include "serif/utils/dependencies/detail/schema.hpp"
|
||||
|
||||
namespace serif::utils::dependencies {
|
||||
/// Describe a node invocation, independently of whether the whole graph request is later accepted.
|
||||
enum class NodeOutcome {
|
||||
completed,
|
||||
rejected,
|
||||
exception
|
||||
};
|
||||
|
||||
/// Completion must be safe during stack unwinding and receives the state created by the before hook.
|
||||
template <typename BeforeT, typename AfterT>
|
||||
concept NodeObserverCallbacks = std::invocable<BeforeT&> &&
|
||||
std::is_object_v<std::invoke_result_t<BeforeT&>> && std::move_constructible<std::invoke_result_t<BeforeT&>> &&
|
||||
requires(AfterT& after, std::invoke_result_t<BeforeT&>& state, NodeOutcome outcome) {
|
||||
{ std::invoke(after, state, outcome) } noexcept -> std::same_as<void>;
|
||||
};
|
||||
|
||||
template <typename KeyT, typename BeforeT, typename AfterT> requires NodeObserverCallbacks<BeforeT, AfterT>
|
||||
struct NodeObserver {
|
||||
BeforeT before;
|
||||
AfterT after;
|
||||
};
|
||||
|
||||
/// An all-node callback receives the compiled result identity before creating its invocation state.
|
||||
template <typename BeforeT, typename AfterT, typename KeyT>
|
||||
concept KeyedNodeObserverCallbacks = std::invocable<BeforeT&, std::type_identity<KeyT>> &&
|
||||
std::is_object_v<std::invoke_result_t<BeforeT&, std::type_identity<KeyT>>> &&
|
||||
std::move_constructible<std::invoke_result_t<BeforeT&, std::type_identity<KeyT>>> &&
|
||||
requires(AfterT& after, std::invoke_result_t<BeforeT&, std::type_identity<KeyT>>& state, NodeOutcome outcome) {
|
||||
{ std::invoke(after, state, outcome) } noexcept -> std::same_as<void>;
|
||||
};
|
||||
|
||||
/// One registration can inspect every result without building a graph-sized observer tuple.
|
||||
template <typename BeforeT, typename AfterT>
|
||||
struct AllNodesObserver {
|
||||
BeforeT before;
|
||||
AfterT after;
|
||||
};
|
||||
|
||||
template <typename InputT, typename ConsumerT, typename FunctionT>
|
||||
struct EdgeObserver {
|
||||
FunctionT function;
|
||||
};
|
||||
|
||||
namespace detail {
|
||||
template <typename T>
|
||||
inline constexpr bool is_observer_v = false;
|
||||
|
||||
template <typename KeyT, typename BeforeT, typename AfterT>
|
||||
inline constexpr bool is_observer_v<NodeObserver<KeyT, BeforeT, AfterT>> = true;
|
||||
|
||||
template <typename BeforeT, typename AfterT>
|
||||
inline constexpr bool is_observer_v<AllNodesObserver<BeforeT, AfterT>> = true;
|
||||
|
||||
template <typename InputT, typename ConsumerT, typename FunctionT>
|
||||
inline constexpr bool is_observer_v<EdgeObserver<InputT, ConsumerT, FunctionT>> = true;
|
||||
|
||||
template <typename ObserverT, typename KeyT>
|
||||
inline constexpr bool observes_node_v = false;
|
||||
|
||||
template <typename KeyT, typename BeforeT, typename AfterT>
|
||||
inline constexpr bool observes_node_v<NodeObserver<KeyT, BeforeT, AfterT>, KeyT> = true;
|
||||
|
||||
template <typename BeforeT, typename AfterT, typename KeyT>
|
||||
inline constexpr bool observes_node_v<AllNodesObserver<BeforeT, AfterT>, KeyT> = true;
|
||||
|
||||
/// Preserve the existing nullary callback contract for individually selected nodes.
|
||||
template <typename KeyT, typename BeforeT, typename AfterT>
|
||||
[[nodiscard]] decltype(auto) begin_node(NodeObserver<KeyT, BeforeT, AfterT>& observer) {
|
||||
return std::invoke(observer.before);
|
||||
}
|
||||
|
||||
/// Bind the identity once so generic registrations use the same exception-safe invocation scope.
|
||||
template <typename KeyT, typename BeforeT, typename AfterT>
|
||||
[[nodiscard]] decltype(auto) begin_node(AllNodesObserver<BeforeT, AfterT>& observer) {
|
||||
return std::invoke(observer.before, std::type_identity<KeyT>{});
|
||||
}
|
||||
|
||||
/// Unrelated registrations do not observe this edge.
|
||||
template <typename InputT, typename ConsumerT, typename ObserverT, typename ValueT>
|
||||
void observe_input(ObserverT&, const ValueT&) {}
|
||||
|
||||
/// Edge observers read the numerical value even when its consumer receives an owning handle.
|
||||
template <typename InputT, typename ConsumerT, typename FunctionT, typename ValueT>
|
||||
void observe_input(EdgeObserver<InputT, ConsumerT, FunctionT>& observer, const ValueT& value) {
|
||||
std::invoke(observer.function, value);
|
||||
}
|
||||
|
||||
/// Each started hook owns one invocation state and finishes once, including on exceptional exit.
|
||||
template <typename BeforeT, typename AfterT>
|
||||
class NodeObservation {
|
||||
public:
|
||||
/// If before fails, construction fails and no matching completion is due.
|
||||
NodeObservation(BeforeT& before, AfterT& after) :
|
||||
m_after(after), m_state(std::invoke(before)), m_exceptions(std::uncaught_exceptions()) {}
|
||||
|
||||
NodeObservation(const NodeObservation&) = delete;
|
||||
NodeObservation& operator=(const NodeObservation&) = delete;
|
||||
|
||||
/// A throwing result transfer also interrupts the invocation; no catch/rethrow is needed.
|
||||
~NodeObservation() noexcept {
|
||||
const NodeOutcome outcome = std::uncaught_exceptions() > m_exceptions ? NodeOutcome::exception : m_outcome;
|
||||
std::invoke(m_after, m_state, outcome);
|
||||
}
|
||||
|
||||
/// Distinguish an ordinary value from a computation's value-based rejection.
|
||||
template <typename ResultT>
|
||||
void returned(const ResultT& result) noexcept {
|
||||
m_outcome = NodeOutcome::completed;
|
||||
if constexpr (Result<ResultT>::fallible) {
|
||||
if (!result) {
|
||||
m_outcome = NodeOutcome::rejected;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private:
|
||||
AfterT& m_after;
|
||||
std::invoke_result_t<BeforeT&> m_state;
|
||||
int m_exceptions;
|
||||
NodeOutcome m_outcome{NodeOutcome::exception};
|
||||
};
|
||||
}
|
||||
|
||||
/// Own registrations; execution state lasts for one invocation and is never part of the numerical cache.
|
||||
template <typename... Registrations> requires (detail::is_observer_v<Registrations> && ...)
|
||||
class Observers {
|
||||
public:
|
||||
/// Copy or move callbacks into this bundle without running them.
|
||||
explicit Observers(Registrations... observers) : m_observers(std::move(observers)...) {}
|
||||
|
||||
/// Nest matching node hooks so before runs in registration order and after in reverse order.
|
||||
template <typename KeyT, std::size_t Position = 0, typename FunctionT>
|
||||
[[nodiscard]] auto evaluate(FunctionT&& function) {
|
||||
if constexpr (Position == sizeof...(Registrations)) {
|
||||
return std::invoke(function);
|
||||
} else {
|
||||
auto& observer = std::get<Position>(m_observers);
|
||||
if constexpr (detail::observes_node_v<std::remove_cvref_t<decltype(observer)>, KeyT>) {
|
||||
auto before = [&observer]() -> decltype(auto) { return detail::begin_node<KeyT>(observer); };
|
||||
detail::NodeObservation observation(before, observer.after);
|
||||
auto result = evaluate<KeyT, Position + 1>(function);
|
||||
observation.returned(result);
|
||||
return result;
|
||||
} else {
|
||||
return evaluate<KeyT, Position + 1>(function);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The cache visits inputs in declaration order; each edge's callbacks follow registration order.
|
||||
template <typename InputT, typename ConsumerT, typename ValueT>
|
||||
void edge(const ValueT& value) {
|
||||
std::apply([&](auto&... observers) {
|
||||
(detail::observe_input<InputT, ConsumerT>(observers, value), ...);
|
||||
}, m_observers);
|
||||
}
|
||||
|
||||
private:
|
||||
std::tuple<Registrations...> m_observers;
|
||||
};
|
||||
|
||||
/// Bind before/after callbacks to a result key; callback captures determine any external storage lifetime.
|
||||
template <typename KeyT, typename BeforeT, typename AfterT>
|
||||
requires NodeObserverCallbacks<std::decay_t<BeforeT>, std::decay_t<AfterT>> &&
|
||||
std::constructible_from<std::decay_t<BeforeT>, BeforeT> && std::constructible_from<std::decay_t<AfterT>, AfterT>
|
||||
[[nodiscard]] auto observe_node(BeforeT&& before, AfterT&& after) {
|
||||
return NodeObserver<KeyT, std::decay_t<BeforeT>, std::decay_t<AfterT>>{
|
||||
.before = std::forward<BeforeT>(before), .after = std::forward<AfterT>(after)};
|
||||
}
|
||||
|
||||
/// Observe all executed nodes; cache construction checks the callback contract for every result key.
|
||||
template <typename BeforeT, typename AfterT>
|
||||
requires std::constructible_from<std::decay_t<BeforeT>, BeforeT> && std::constructible_from<std::decay_t<AfterT>, AfterT>
|
||||
[[nodiscard]] auto observe_nodes(BeforeT&& before, AfterT&& after) {
|
||||
return AllNodesObserver<std::decay_t<BeforeT>, std::decay_t<AfterT>>{
|
||||
.before = std::forward<BeforeT>(before), .after = std::forward<AfterT>(after)};
|
||||
}
|
||||
|
||||
/// Bind a read-only callback to one input of one consumer; graph construction validates the edge and value type.
|
||||
template <typename InputT, typename ConsumerT, typename FunctionT>
|
||||
requires std::constructible_from<std::decay_t<FunctionT>, FunctionT>
|
||||
[[nodiscard]] auto observe_edge(FunctionT&& function) {
|
||||
return EdgeObserver<InputT, ConsumerT, std::decay_t<FunctionT>>{.function = std::forward<FunctionT>(function)};
|
||||
}
|
||||
|
||||
/// Collect any number of registrations, including several observers of the same node or edge.
|
||||
template <typename... Registrations>
|
||||
requires (detail::is_observer_v<std::remove_cvref_t<Registrations>> && ...) &&
|
||||
(std::constructible_from<std::remove_cvref_t<Registrations>, Registrations> && ...)
|
||||
[[nodiscard]] auto make_observers(Registrations&&... observers) {
|
||||
return Observers<std::remove_cvref_t<Registrations>...>(std::forward<Registrations>(observers)...);
|
||||
}
|
||||
|
||||
/// Default high-level binding adds no observation storage, timers, or callback work.
|
||||
struct NoGraphObservers {
|
||||
/// The graph tag allows optional factories to specialize their catalogs without inspecting numerical values.
|
||||
template <typename GraphT>
|
||||
[[nodiscard]] auto operator()(std::type_identity<GraphT>) const {
|
||||
return Observers<>{};
|
||||
}
|
||||
};
|
||||
|
||||
namespace detail {
|
||||
/// Nodes are identified by result keys, including constant computations without inputs.
|
||||
template <typename GraphT, typename BindingsT, typename KeyT, typename BeforeT, typename AfterT>
|
||||
[[nodiscard]] consteval bool valid_observer(NodeObserver<KeyT, BeforeT, AfterT>*) {
|
||||
return ContainsKey<KeyT, typename GraphT::Results>;
|
||||
}
|
||||
|
||||
/// Use a bounded array scan rather than a graph-sized fold expression at the compiler's nesting limit.
|
||||
template <typename BeforeT, typename AfterT, typename... Keys>
|
||||
[[nodiscard]] consteval bool valid_all_nodes(KeyList<Keys...>) {
|
||||
constexpr std::array<bool, sizeof...(Keys)> valid{KeyedNodeObserverCallbacks<BeforeT, AfterT, Keys>...};
|
||||
for (const bool entry : valid) {
|
||||
if (!entry) { return false; }
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/// A generic callback must accept every node, including constant computations without inputs.
|
||||
template <typename GraphT, typename BindingsT, typename BeforeT, typename AfterT>
|
||||
[[nodiscard]] consteval bool valid_observer(AllNodesObserver<BeforeT, AfterT>*) {
|
||||
return valid_all_nodes<BeforeT, AfterT>(typename GraphT::Results{});
|
||||
}
|
||||
|
||||
/// Use declared inputs to include source edges as well as computed prerequisites.
|
||||
template <typename GraphT, typename BindingsT, typename InputT, typename ConsumerT, typename FunctionT>
|
||||
[[nodiscard]] consteval bool valid_observer(EdgeObserver<InputT, ConsumerT, FunctionT>*) {
|
||||
if constexpr (!ContainsKey<ConsumerT, typename GraphT::Results>) {
|
||||
return false;
|
||||
} else {
|
||||
using Consumer = GraphT::template ComputationAt<GraphT::template index<ConsumerT>()>;
|
||||
if constexpr (!ContainsKey<InputT, typename Consumer::Inputs>) {
|
||||
return false;
|
||||
} else {
|
||||
return requires(FunctionT& function, const bound_type_t<BindingsT, InputT>& value) {
|
||||
{ std::invoke(function, value) } -> std::same_as<void>;
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
template <typename ObserversT, typename GraphT, typename ContextT, bool Valid = ComputationGraphFor<GraphT, ContextT>>
|
||||
struct ValidObservers : std::false_type {};
|
||||
|
||||
template <typename... Registrations, typename GraphT, typename ContextT>
|
||||
struct ValidObservers<Observers<Registrations...>, GraphT, ContextT, true> : std::bool_constant<
|
||||
(valid_observer<GraphT, typename ComputationGraphSchema<GraphT, ContextT>::ResolvedBindings>(static_cast<Registrations*>(nullptr)) && ...)> {};
|
||||
}
|
||||
|
||||
/// Reject unknown nodes, nonexistent edges, and callbacks that cannot observe the graph's immutable values.
|
||||
template <typename ObserversT, typename GraphT, typename ContextT>
|
||||
concept ObserversFor = detail::ValidObservers<std::remove_cvref_t<ObserversT>, std::remove_cvref_t<GraphT>, std::remove_cvref_t<ContextT>>::value;
|
||||
}
|
||||
|
||||
template <>
|
||||
struct std::formatter<serif::utils::dependencies::NodeOutcome> : std::formatter<std::string_view> {
|
||||
auto format(const serif::utils::dependencies::NodeOutcome& outcome, auto& ctx) const {
|
||||
std::string_view name = "unknown";
|
||||
switch (outcome) {
|
||||
case serif::utils::dependencies::NodeOutcome::completed: name = "completed"; break;
|
||||
case serif::utils::dependencies::NodeOutcome::rejected: name = "rejected"; break;
|
||||
case serif::utils::dependencies::NodeOutcome::exception: name = "exception"; break;
|
||||
}
|
||||
return std::formatter<std::string_view>::format(name, ctx);
|
||||
}
|
||||
};
|
||||
55
src/include/serif/utils/dependencies/preparation.hpp
Normal file
55
src/include/serif/utils/dependencies/preparation.hpp
Normal file
@@ -0,0 +1,55 @@
|
||||
#pragma once
|
||||
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <cstddef>
|
||||
#include <unordered_map>
|
||||
#include <string_view>
|
||||
|
||||
#include "serif/utils/dependencies/detail/type_lists.hpp"
|
||||
#include "serif/meta/enums.hpp"
|
||||
|
||||
namespace serif::utils::dependencies {
|
||||
enum class PreparationMode {
|
||||
reuse_unchanged,
|
||||
rebuild
|
||||
};
|
||||
enum class PreparationStatus {
|
||||
not_required,
|
||||
reused,
|
||||
computed
|
||||
};
|
||||
|
||||
inline std::unordered_map<PreparationStatus, std::string_view> preparation_status_name_map = meta::enum_to_enum_string_map<PreparationStatus>();
|
||||
inline std::unordered_map<PreparationMode, std::string_view> preparation_mode_name_map = meta::enum_to_enum_string_map<PreparationMode>();
|
||||
|
||||
template <typename ErrorT>
|
||||
struct PreparationFailure {
|
||||
std::size_t computation_index;
|
||||
ErrorT error;
|
||||
};
|
||||
|
||||
template <IsKeyList ResultsT, IsKeyList SourcesT>
|
||||
struct PreparationReport {
|
||||
std::array<PreparationStatus, detail::KeyListSize<ResultsT>::value> statuses{};
|
||||
std::array<bool, detail::KeyListSize<SourcesT>::value> changed_sources{};
|
||||
|
||||
/// Inspect a computation by its result key rather than an implementation index.
|
||||
template <typename KeyT> requires detail::contains_key_v<KeyT, ResultsT>
|
||||
[[nodiscard]] PreparationStatus status() const {
|
||||
return statuses[detail::type_index<KeyT>(typename ResultsT::Keys{})];
|
||||
}
|
||||
|
||||
/// An unused source is reported unchanged because it was not inspected by this request.
|
||||
template <typename KeyT> requires detail::contains_key_v<KeyT, SourcesT>
|
||||
[[nodiscard]] bool source_changed() const {
|
||||
return changed_sources[detail::type_index<KeyT>(typename SourcesT::Keys{})];
|
||||
}
|
||||
|
||||
/// Count only computations evaluated during this successful preparation.
|
||||
[[nodiscard]] std::size_t computed_count() const { return std::count(statuses.begin(), statuses.end(), PreparationStatus::computed); }
|
||||
|
||||
/// Count requested computations served from the accepted cache.
|
||||
[[nodiscard]] std::size_t reused_count() const { return std::count(statuses.begin(), statuses.end(), PreparationStatus::reused); }
|
||||
};
|
||||
}
|
||||
76
src/include/serif/utils/dependencies/resource.hpp
Normal file
76
src/include/serif/utils/dependencies/resource.hpp
Normal file
@@ -0,0 +1,76 @@
|
||||
#pragma once
|
||||
|
||||
#include <functional>
|
||||
#include <type_traits>
|
||||
#include <utility>
|
||||
#include "serif/utils/dependencies/snapshot.hpp"
|
||||
|
||||
namespace serif::utils::dependencies {
|
||||
namespace detail {
|
||||
template <typename T>
|
||||
struct OwnedValue {
|
||||
using Target = T;
|
||||
T value;
|
||||
|
||||
/// Expose a source for read-only computation.
|
||||
[[nodiscard]] const T& get() const { return value; }
|
||||
};
|
||||
|
||||
template <typename T>
|
||||
struct BorrowedValue {
|
||||
using Target = T;
|
||||
std::reference_wrapper<const T> value;
|
||||
|
||||
/// Borrowing does not extend the lifetime of the source.
|
||||
[[nodiscard]] const T& get() const { return value.get(); }
|
||||
};
|
||||
}
|
||||
|
||||
template <typename KeyT, typename StorageT, typename PolicyT>
|
||||
class Resource {
|
||||
public:
|
||||
using Key = KeyT;
|
||||
using Target = typename StorageT::Target;
|
||||
using Policy = PolicyT;
|
||||
|
||||
/// Keep storage and its tracking policy together under one role key.
|
||||
Resource(StorageT storage, PolicyT policy) : m_storage(std::move(storage)), m_policy(std::move(policy)) {}
|
||||
|
||||
/// Read the resource without allowing computations to mutate their inputs.
|
||||
[[nodiscard]] const Target& get() const { return m_storage.get(); }
|
||||
|
||||
/// Expose tracking separately from the value passed to a computation.
|
||||
[[nodiscard]] const PolicyT& policy() const { return m_policy; }
|
||||
|
||||
private:
|
||||
StorageT m_storage;
|
||||
PolicyT m_policy;
|
||||
};
|
||||
|
||||
template <typename T>
|
||||
inline constexpr bool is_resource_v = false;
|
||||
|
||||
template <typename KeyT, typename StorageT, typename PolicyT>
|
||||
inline constexpr bool is_resource_v<Resource<KeyT, StorageT, PolicyT>> = true;
|
||||
|
||||
template <typename T>
|
||||
concept IsResource = is_resource_v<std::remove_cvref_t<T>>;
|
||||
|
||||
/// Store a value in the context; rvalues transfer ownership and lvalues are copied.
|
||||
template <typename KeyT, typename ValueT, typename PolicyT = ValueSnapshot>
|
||||
[[nodiscard]] auto own(ValueT&& value, PolicyT policy = {}) {
|
||||
using Storage = detail::OwnedValue<std::decay_t<ValueT>>;
|
||||
return Resource<KeyT, Storage, PolicyT>(Storage{.value = std::forward<ValueT>(value)}, std::move(policy));
|
||||
}
|
||||
|
||||
/// Bind an existing object whose lifetime must cover every use of the context.
|
||||
template <typename KeyT, typename ValueT, typename PolicyT = ValueSnapshot>
|
||||
[[nodiscard]] auto borrow(ValueT& value, PolicyT policy = {}) {
|
||||
using Storage = detail::BorrowedValue<ValueT>;
|
||||
return Resource<KeyT, Storage, PolicyT>(Storage{.value = std::cref(value)}, std::move(policy));
|
||||
}
|
||||
|
||||
/// Borrowed storage must never refer to a temporary, including a const temporary.
|
||||
template <typename KeyT, typename ValueT, typename PolicyT = ValueSnapshot>
|
||||
auto borrow(const ValueT&&, PolicyT = {}) = delete;
|
||||
}
|
||||
63
src/include/serif/utils/dependencies/snapshot.hpp
Normal file
63
src/include/serif/utils/dependencies/snapshot.hpp
Normal file
@@ -0,0 +1,63 @@
|
||||
#pragma once
|
||||
|
||||
#include <concepts>
|
||||
#include <functional>
|
||||
#include <memory>
|
||||
#include <type_traits>
|
||||
#include <utility>
|
||||
|
||||
namespace serif::utils::dependencies {
|
||||
/// Default tracking owns an exact value copy; noncopyable resources supply another policy.
|
||||
struct ValueSnapshot {
|
||||
/// Keep the accepted value independent of subsequent edits to a borrowed source.
|
||||
template <typename T> requires std::copy_constructible<T> && std::equality_comparable<T>
|
||||
[[nodiscard]] T capture(const T& value) const { return value; }
|
||||
|
||||
/// Compare content rather than a hash that could silently miss a change.
|
||||
template <typename T> requires std::equality_comparable<T>
|
||||
[[nodiscard]] bool matches(const T& captured, const T& value) const { return captured == value; }
|
||||
};
|
||||
|
||||
template <typename FunctionT>
|
||||
class ProjectedSnapshot {
|
||||
public:
|
||||
/// Own the policy callable so its configuration travels with the source.
|
||||
explicit ProjectedSnapshot(FunctionT function) : m_function(std::move(function)) {}
|
||||
|
||||
/// Capture a comparable value such as a resource revision or a tuple of revisions.
|
||||
template <typename T> requires std::invocable<const FunctionT&, const T&>
|
||||
[[nodiscard]] auto capture(const T& value) const { return std::invoke(m_function, value); }
|
||||
|
||||
/// Recompute the tracking value without copying the resource itself.
|
||||
template <typename CapturedT, typename T>
|
||||
requires requires(const CapturedT& captured, const ProjectedSnapshot& policy, const T& value) {
|
||||
{ captured == policy.capture(value) } -> std::convertible_to<bool>;
|
||||
}
|
||||
[[nodiscard]] bool matches(const CapturedT& captured, const T& value) const { return captured == capture(value); }
|
||||
|
||||
private:
|
||||
FunctionT m_function;
|
||||
};
|
||||
|
||||
/// Select the state that determines whether a source needs recomputation.
|
||||
template <typename FunctionT>
|
||||
[[nodiscard]] auto track_by(FunctionT&& function) {
|
||||
return ProjectedSnapshot<std::decay_t<FunctionT>>(std::forward<FunctionT>(function));
|
||||
}
|
||||
|
||||
template <typename PolicyT, typename ValueT>
|
||||
concept SnapshotPolicyFor = requires(const PolicyT& policy, const ValueT& value) {
|
||||
policy.capture(value);
|
||||
requires std::is_object_v<decltype(policy.capture(value))>;
|
||||
requires std::move_constructible<decltype(policy.capture(value))>;
|
||||
{ policy.matches(std::declval<const decltype(policy.capture(value))&>(), value) } -> std::convertible_to<bool>;
|
||||
} && (!requires(const PolicyT& policy, const ValueT& value) { policy.identity(value); } ||
|
||||
requires(const PolicyT& policy, const ValueT& value) { { policy.identity(value) } -> std::convertible_to<const void*>; });
|
||||
|
||||
/// Views may identify their underlying resource; ordinary sources retain object identity.
|
||||
template <typename PolicyT, typename ValueT> requires SnapshotPolicyFor<PolicyT, ValueT>
|
||||
[[nodiscard]] const void* source_identity(const PolicyT& policy, const ValueT& value) {
|
||||
if constexpr (requires { policy.identity(value); }) { return policy.identity(value); }
|
||||
else { return std::addressof(value); }
|
||||
}
|
||||
}
|
||||
127
src/include/serif/utils/dependencies/timing.hpp
Normal file
127
src/include/serif/utils/dependencies/timing.hpp
Normal file
@@ -0,0 +1,127 @@
|
||||
#pragma once
|
||||
|
||||
#include <algorithm>
|
||||
#include <chrono>
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "nameof.hpp"
|
||||
#include "serif/utils/dependencies/observers.hpp"
|
||||
|
||||
namespace serif::utils::dependencies {
|
||||
/** @brief Wall time for attempted node work, independently of later cache or Newton acceptance. */
|
||||
struct NodeTiming {
|
||||
std::size_t index{};
|
||||
std::string key_name;
|
||||
std::uint64_t calls{};
|
||||
std::uint64_t completed{};
|
||||
std::uint64_t rejected{};
|
||||
std::uint64_t exceptions{};
|
||||
double seconds{};
|
||||
double maximum_seconds{};
|
||||
|
||||
/** @brief Closing a node must neither allocate nor fail during stack unwinding. */
|
||||
void finish(double duration, NodeOutcome outcome) noexcept {
|
||||
++calls;
|
||||
seconds += duration;
|
||||
maximum_seconds = std::max(maximum_seconds, duration);
|
||||
switch (outcome) {
|
||||
case NodeOutcome::completed: ++completed; break;
|
||||
case NodeOutcome::rejected: ++rejected; break;
|
||||
case NodeOutcome::exception: ++exceptions; break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/** @brief One graph binding's declaration-ordered catalog, including nodes which never execute. */
|
||||
struct GraphTimingRun {
|
||||
std::vector<NodeTiming> nodes;
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief Collect independent graph bindings without mixing timers into numerical restart history.
|
||||
* @note One cache executes its nodes serially, although each node may contain parallel kernels.
|
||||
* Concurrent caches require separate collectors. Returned observers own their run; the collector
|
||||
* need only outlive factory invocation. Formatting and sorting belong outside timed execution.
|
||||
*/
|
||||
class GraphTiming {
|
||||
public:
|
||||
/** @brief Inspect completed and active measurements without changing their catalog or indices. */
|
||||
[[nodiscard]] const std::vector<std::shared_ptr<const GraphTimingRun>>& runs() const noexcept { return m_runs; }
|
||||
|
||||
/** @brief Allocate names and counters once, before any measured node begins. */
|
||||
template <typename GraphT>
|
||||
[[nodiscard]] std::shared_ptr<GraphTimingRun> start_run(std::type_identity<GraphT>) {
|
||||
auto run = std::make_shared<GraphTimingRun>();
|
||||
run->nodes = []<typename... Keys>(KeyList<Keys...>) {
|
||||
return std::vector<NodeTiming>{NodeTiming{.index = GraphT::template index<Keys>(),
|
||||
.key_name = std::string{nameof::nameof_type<Keys>()}}...};
|
||||
}(typename GraphT::Results{});
|
||||
m_runs.push_back(run);
|
||||
return run;
|
||||
}
|
||||
|
||||
private:
|
||||
std::vector<std::shared_ptr<const GraphTimingRun>> m_runs;
|
||||
};
|
||||
|
||||
/** @brief Read monotonic elapsed wall time without changing physical computations. */
|
||||
struct SteadyNodeClock {
|
||||
/** @brief Use one nonthrowing time source for node entry and exit. */
|
||||
[[nodiscard]] std::chrono::steady_clock::time_point operator()() const noexcept {
|
||||
return std::chrono::steady_clock::now();
|
||||
}
|
||||
};
|
||||
|
||||
namespace detail {
|
||||
template <typename TimePoint>
|
||||
inline constexpr bool node_time_point_v = false;
|
||||
|
||||
template <typename Clock, typename Duration>
|
||||
inline constexpr bool node_time_point_v<std::chrono::time_point<Clock, Duration>> = std::is_arithmetic_v<typename Duration::rep>;
|
||||
}
|
||||
|
||||
/** @brief Restrict injected clocks to ordinary arithmetic chrono values safe in a closing callback. */
|
||||
template <typename Clock>
|
||||
concept NodeTimingClock = std::is_nothrow_invocable_v<Clock&> &&
|
||||
detail::node_time_point_v<std::invoke_result_t<Clock&>>;
|
||||
|
||||
/** @brief Bind one generic timing observer after the model's complete graph type is known. */
|
||||
template <NodeTimingClock Clock = SteadyNodeClock>
|
||||
struct GraphTimingFactory {
|
||||
GraphTiming& timing;
|
||||
std::shared_ptr<Clock> clock;
|
||||
|
||||
/**
|
||||
* @brief Include edge callbacks and the node callable, excluding source checks and cache publication.
|
||||
* @note Reused or unrequested nodes produce no sample. Keep regular wall/linear-solve measurements
|
||||
* alongside these durations: action applications generally execute after graph preparation.
|
||||
*/
|
||||
template <typename GraphT>
|
||||
[[nodiscard]] auto operator()(std::type_identity<GraphT> graph) const {
|
||||
const auto run = timing.start_run(graph);
|
||||
using TimePoint = std::invoke_result_t<Clock&>;
|
||||
struct Invocation {
|
||||
std::size_t index;
|
||||
TimePoint started;
|
||||
};
|
||||
return make_observers(observe_nodes(
|
||||
[clock = clock]<typename Key>(std::type_identity<Key>) noexcept {
|
||||
return Invocation{.index = GraphT::template index<Key>(), .started = std::invoke(*clock)};
|
||||
},
|
||||
[run, clock = clock](Invocation& invocation, NodeOutcome outcome) noexcept {
|
||||
const double duration = std::chrono::duration<double>{std::invoke(*clock) - invocation.started}.count();
|
||||
run->nodes[invocation.index].finish(duration, outcome);
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
/** @brief Opt into node timings; a custom clock supports deterministic accounting tests without sleeps. */
|
||||
template <NodeTimingClock Clock = SteadyNodeClock>
|
||||
[[nodiscard]] auto time_graph_nodes(GraphTiming& timing, Clock clock = {}) {
|
||||
return GraphTimingFactory<Clock>{.timing = timing, .clock = std::make_shared<Clock>(std::move(clock))};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user