/// @file dpf/eval_walk.hpp /// @brief Walk helpers that fold a small operation into an existing DPF walk. /// @details These are the calls the protocol mockups in /// `examples/applications/` had to build by hand around a walk: /// - `dpf::rotate{s}` weights the walk by `w[(i + s) mod 2^n]` /// (Duoram's read, Pika's lookup) with no second vector. /// - `eval_full_add_into(buf, key)` adds a full-domain expansion /// into a buffer the caller already holds (Prio's histogram, /// Express's mailbox, Duoram's update). A `rotate` overload shifts /// the write; a `sketch` overload folds the audit in the same pass. /// - `dpf::cyclic_shift(buf, s)` rotates a materialized share buffer, /// `new[i] = old[(i - s) mod n]`. /// - `dpf::pack_bit_columns(keys...)` runs the full-domain bit walk /// once per key and packs one integer per row (lane `e` is key `e`), /// the digit BitMore reads when the server count is a power of two. /// - `dpf::mod_bit_columns<ℓ>(keys...)` is that digit modulo `ℓ` when /// the server count is not a power of two. The first key is still /// the low bit. The running residue stays in a byte through `ℓ = 128` /// and in a 16-bit lane through `ℓ = 32768`. /// - `eval_prefixes(out, key)` returns the `2^N` prefix shares, /// and `eval_prefix_inner_product(out, key, values)` dots them /// with `values` in one walk to depth `N` (Poplar, PRAC). /// - `idpf_eval_ctx` / `eval_until` (see `dpf/eval_until.hpp`) resume /// under a live prefix list instead of materializing `2^N` nodes. /// @copyright Copyright (c) 2019-2026 Ryan Henry and [others](@ref authors) /// @license Released under a GNU General Public v2.0 (GPLv2) license. #ifndef LIBDPF_INCLUDE_DPF_EVAL_WALK_HPP__ #define LIBDPF_INCLUDE_DPF_EVAL_WALK_HPP__ #include #include #include #include #include #include #include #include #include #include "hedley/hedley.h" #include "dpf/bitmore_mod.hpp" #include "dpf/eval_target.hpp" #include "dpf/eval_full.hpp" #include "dpf/eval_inner_product.hpp" #include "dpf/eval_unified.hpp" #include "dpf/output_buffer.hpp" #include "dpf/utils.hpp" #include "dpf/verifiable.hpp" namespace dpf { /// @brief Rotation offset applied inside a walk: domain point `i` uses lane /// `(i + shift) mod 2^n`. Pass to `eval_full_inner_product` / /// `eval_full_add_into` so the caller does not build a second vector. struct rotate { std::size_t shift; }; namespace detail_walk { /// @brief Number of input points `2^n` of `KeyT`, as a `std::size_t`. template HEDLEY_NO_THROW constexpr std::size_t domain_size() noexcept { constexpr std::size_t bits = utils::bitlength_of_v; static_assert(bits < 8 * sizeof(std::size_t), "walk helper: input domain does not fit a std::size_t index"); return std::size_t{1} << bits; } /// @brief `w[(i + shift) mod n]`, a read-only rotated view of `w`. template struct rotated_weights { const Weights & w; std::size_t shift; std::size_t n; HEDLEY_ALWAYS_INLINE decltype(auto) operator[](std::size_t i) const { return w[(i + shift) % n]; } }; template struct has_raw : std::false_type {}; template struct has_raw().raw())>> : std::true_type {}; /// @brief The group element carried by an eval buffer slot. A one-word share /// (`additive`, `subtractive`, or `additive3`) exposes it through /// `.raw()`. A replicated share has two components and is returned /// as itself. A raw output type is returned unchanged. template HEDLEY_ALWAYS_INLINE auto group_value(const T & v) { if constexpr (has_raw::value) return v.raw(); else return v; } /// @brief Call `fn` on `keys` from the last key down to the first. /// @details `mod_bit_columns` inserts the high bit first. Key 0 stays the low /// bit, matching `pack_bit_columns`. template void insert_keys_msb_first(Fn && fn, Tuple && keys, std::index_sequence) { constexpr std::size_t n = sizeof...(I); (fn(std::get(keys)), ...); } } // namespace detail_walk // --------------------------------------------------------------------------- // dpf::rotate on the paired full-domain inner product // --------------------------------------------------------------------------- /// @brief `sum_i DPF_I(i) * rows[(i + rot.shift) mod 2^n]` over the whole domain. /// @details The same paired walk as `eval_full_inner_product(paired, key, rows)`, /// but the weight at domain point `i` is read from `rows` rotated by /// `rot.shift`. Duoram's read and Pika's lookup pass the unrotated /// table and this offset instead of materializing a rotated copy. template HEDLEY_WARN_UNUSED_RESULT auto eval_full_inner_product(paired_t, const DpfKey & dpf, Rows && rows, rotate rot) { constexpr std::size_t n = detail_walk::domain_size(); detail_walk::rotated_weights> view{ rows, rot.shift % n, n}; return eval_full_inner_product(paired, dpf, view); } // --------------------------------------------------------------------------- // eval_full_add_into(buf, key [, rotate | sketch]) // --------------------------------------------------------------------------- /// @brief Add a full-domain expansion of output `I` into `buf` in place. /// @details `buf[i] += DPF_I(i)` (the leaf share's group element) for every /// domain point `i`. `buf` already holds the caller's running shares /// (Prio's histogram, Express's mailbox, Duoram's update); its element /// type must support `+` with the leaf share's group element. /// \complexity One full-domain expansion, `Θ(2^n)`, plus one `+` per point. template && !is_multilevel_key_v, bool> = true> void eval_full_add_into(Buffer & buf, const DpfKey & dpf) // NOLINT(runtime/references) { auto result = eval_full(dpf); auto & iter = result.second; std::size_t i = 0; for (auto it = std::begin(iter); it != std::end(iter); ++it, ++i) buf[i] = buf[i] + detail_walk::group_value(*it); } /// @brief Add a full-domain expansion into `buf`, shifted by `rot`. /// @details `buf[(i + rot.shift) mod 2^n] += DPF_I(i)`. Duoram's update writes /// the payload placed at `r` into the memory slot `r + shift`. /// \complexity One full-domain expansion, `Θ(2^n)`, plus one `+` per point. template && !is_multilevel_key_v, bool> = true> void eval_full_add_into(Buffer & buf, const DpfKey & dpf, rotate rot) // NOLINT(runtime/references) { constexpr std::size_t n = detail_walk::domain_size(); const std::size_t s = rot.shift % n; auto result = eval_full(dpf); auto & iter = result.second; std::size_t i = 0; for (auto it = std::begin(iter); it != std::end(iter); ++it, ++i) { const std::size_t j = (i + s) % n; buf[j] = buf[j] + detail_walk::group_value(*it); } } /// @brief Add a full-domain expansion into `buf` and fold the audit sketch. /// @details `buf[i] += DPF_I(i)` for every point, and each written share is /// absorbed into `sk` — the same one-hot audit as /// `eval_full(key, sketch(σ))`, in the same pass. Express's mailbox /// write becomes one call instead of a point loop and a second fold. /// \complexity One full-domain expansion, `Θ(2^n)`, plus one `+` and one /// absorb per point. template && !is_multilevel_key_v, bool> = true> void eval_full_add_into(Buffer & buf, const DpfKey & dpf, sketch_ref sk) // NOLINT(runtime/references) { static_assert(DpfKey::is_extractable, "eval_full_add_into(..., sketch(σ)): key must carry dpf::extractable"); auto result = eval_full(dpf); auto & iter = result.second; std::size_t i = 0; for (auto it = std::begin(iter); it != std::end(iter); ++it, ++i) { auto g = detail_walk::group_value(*it); buf[i] = buf[i] + g; sk.absorb(g); // fold the raw share, as eval_point(..., sketch) does } } // --------------------------------------------------------------------------- // dpf::cyclic_shift(buffer, s) // --------------------------------------------------------------------------- /// @brief A copy of `buf` rotated so `result[i] == buf[(i - s) mod n]`. /// @details The buffer analogue of `dpf::rotate{s}`: the value at index `k` /// moves to `k + s`. Duoram's update shifts an expanded share buffer /// this way before adding it into memory. template HEDLEY_WARN_UNUSED_RESULT Container cyclic_shift(const Container & buf, std::size_t s) { Container out(buf); const std::size_t n = buf.size(); if (n == 0) return out; const std::size_t sh = s % n; for (std::size_t i = 0; i < n; ++i) out[i] = buf[(i + n - sh) % n]; return out; } // --------------------------------------------------------------------------- // dpf::pack_bit_columns(keys...) // --------------------------------------------------------------------------- /// @brief Pack the full-domain bit expansions of `keys` into one integer per row. /// @details Runs `eval_full` once per key over a shared bit domain and sets bit /// `e` of `result[row]` when key `e` opens to 1 at that row. `L` keys /// need `L <= 8*sizeof(Int)`. BitMore reads `result[row]` as the /// server's `L`-bit digit instead of unpacking one `int` per bit. /// @tparam Int packed digit type (defaults to `std::uint64_t`) /// \complexity One full-domain bit expansion per key, `Θ(L · 2^n)`. template HEDLEY_WARN_UNUSED_RESULT std::vector pack_bit_columns(const First & first, const Rest &... rest) { static_assert(1 + sizeof...(Rest) <= 8 * sizeof(Int), "pack_bit_columns: more keys than bits in the packed digit type"); constexpr std::size_t n = detail_walk::domain_size(); std::vector out(n, Int{0}); std::size_t e = 0; auto do_one = [&](const auto & key) { auto result = eval_full(key); auto & iter = result.second; std::size_t row = 0; for (auto it = std::begin(iter); it != std::end(iter) && row < n; ++it, ++row) { if (static_cast(*it)) out[row] |= static_cast(Int{1} << e); } ++e; }; do_one(first); (do_one(rest), ...); return out; } // --------------------------------------------------------------------------- // dpf::mod_bit_columns<ℓ>(keys...) // --------------------------------------------------------------------------- /// @brief One residue per row: the packed bit columns, modulo `Modulus`. /// @details The same full-domain bit walk as `pack_bit_columns`. Key 0 is the /// low bit, so row `r` opens to `(sum_e bit_e(r) · 2^e) mod Modulus`. /// Bits are folded high-bit first. Through modulus 128 each running /// slot is a byte; through 32768 it is a 16-bit lane. Both use the /// high-nibble partial reduction, and only the finished row is fully /// reduced. Hafiz and Henry §5.3 read `result[row]` as the server's /// digit when the server count is not a power of two. /// @tparam Modulus server count, `2` through `32768` /// @tparam Int residue type (defaults to `std::uint16_t`) /// \complexity One full-domain bit expansion per key, `Θ(L · 2^n)`, and one /// lane insertion per row per key. template HEDLEY_WARN_UNUSED_RESULT std::vector mod_bit_columns(const First & first, const Rest &... rest) { static_assert(Modulus >= 2u && Modulus <= 32768u, "mod_bit_columns: modulus must be in 2..32768"); static_assert(std::is_unsigned_v, "mod_bit_columns: residue type must be unsigned"); static_assert(Modulus - 1u <= static_cast(std::numeric_limits::max()), "mod_bit_columns: residue type cannot hold a value modulo Modulus"); constexpr unsigned lane_bits = Modulus <= 128u ? 8u : 16u; using reg = simde__m256i; using acc = bitmore_accumulator; constexpr std::size_t lanes = lane_bits == 8u ? 32u : 16u; constexpr std::size_t n = detail_walk::domain_size(); const std::size_t chunks = (n + lanes - 1u) / lanes; std::vector running(chunks); const auto keys = std::forward_as_tuple(first, rest...); detail_walk::insert_keys_msb_first([&](const auto & key) { using key_t = std::decay_t; static_assert(detail_walk::domain_size() == n, "mod_bit_columns: every key must share the first key's domain"); auto result = eval_full(key); auto & iter = result.second; auto it = std::begin(iter); const auto end = std::end(iter); for (std::size_t c = 0; c < chunks; ++c) { reg bits{}; if constexpr (lane_bits == 8u) { alignas(32) unsigned char raw[32]{}; for (std::size_t i = 0; i < lanes && it != end; ++i, ++it) raw[i] = static_cast(*it) ? 1u : 0u; std::memcpy(&bits, raw, sizeof(bits)); } else { alignas(32) std::uint16_t raw[16]{}; for (std::size_t i = 0; i < lanes && it != end; ++i, ++it) raw[i] = static_cast(*it) ? 1u : 0u; std::memcpy(&bits, raw, sizeof(bits)); } running[c].insert_bit(bits); } }, keys, std::make_index_sequence<1u + sizeof...(Rest)>{}); std::vector out(n); std::size_t row = 0; for (std::size_t c = 0; c < chunks; ++c) { const reg reduced = running[c].reduced(); if constexpr (lane_bits == 8u) { alignas(32) unsigned char raw[32]{}; std::memcpy(raw, &reduced, sizeof(raw)); for (std::size_t i = 0; i < lanes && row < n; ++i, ++row) out[row] = static_cast(raw[i]); } else { alignas(32) std::uint16_t raw[16]{}; std::memcpy(raw, &reduced, sizeof(raw)); for (std::size_t i = 0; i < lanes && row < n; ++i, ++row) out[row] = static_cast(raw[i]); } } return out; } // --------------------------------------------------------------------------- // eval_prefixes / eval_prefix_inner_product (idpf prefix walk) // --------------------------------------------------------------------------- /// @brief The `2^N` prefix shares of output `I` (prefix length `N`). /// @details One walk to depth `N`; slot `p` is the share on prefix `p`. Poplar /// reads these to score every node at a depth in one pass instead of /// one `eval_point` per node. Returns the `(buffer, iterable)` pair of /// `eval_full(out, key)`. /// \complexity One walk to depth `N`: `Θ(N + 2^N)` interior traversals. template , bool> = true> HEDLEY_WARN_UNUSED_RESULT auto eval_prefixes(out_t, const KeyT & key) { return eval_full(out_t{}, key); } /// @brief `sum_p DPF_I(p) * values[p]` over the `2^N` prefixes of output `I`. /// @details Walks to depth `N` once and dots the prefix shares with `values` /// (indexed by prefix `0 .. 2^N - 1`). PRAC's strides and Poplar's /// "is this prefix heavy?" are this one call, replacing an /// `eval_point` per node. /// \complexity One walk to depth `N`, `Θ(N + 2^N)`, plus one multiply-add per /// prefix into an `O(1)` accumulator. template , bool> = true> HEDLEY_WARN_UNUSED_RESULT auto eval_prefix_inner_product(out_t, const KeyT & key, Values && values) { using lane_t = typename KeyT::input_type; constexpr lane_t lo = lane_t{0}; constexpr lane_t hi = (N >= utils::bitlength_of_v) ? static_cast(~lane_t{0}) : static_cast((lane_t{1} << N) - 1); return eval_inner_product(out_t{}, key, lo, hi, std::forward(values)); } /// @brief XOR of `records[j]` for each listed point where the bit share is set. /// @details Same walk as `eval_sequence` on a bit key, but folds the selected /// records into one accumulator instead of materializing a bit vector. /// Keyword PIR's server response is this one call. template >, int> = 0> HEDLEY_WARN_UNUSED_RESULT auto eval_sequence_xor(const DpfKey & key, ForwardIterator begin, ForwardIterator end, Records && records) { using record_t = std::decay_t; record_t acc{}; auto [buf, iter] = eval_sequence(key, begin, end); std::size_t i = 0; for (auto bit : iter) { if (static_cast(bit)) acc = static_cast(acc ^ records[i]); ++i; } return acc; } /// @brief Evaluate a walk while invoking `fold(index, share)` once per written output. /// @details The fold is a template — inlined the way `sketch_ref::absorb` is. /// Express's audit, Pika's SZ check, and a SNIP input vector each /// supply their own fold over the group they write. template && !is_multilevel_key_v && !std::is_same_v, sketch_ref> && !std::is_same_v, rotate>, bool> = true> void eval_full_add_into(Buffer & buf, const DpfKey & dpf, Fold && fold) // NOLINT(runtime/references) { auto result = eval_full(dpf); auto & iter = result.second; std::size_t i = 0; for (auto it = std::begin(iter); it != std::end(iter); ++it, ++i) { auto g = detail_walk::group_value(*it); buf[i] = buf[i] + g; fold(i, g); } } } // namespace dpf #endif // LIBDPF_INCLUDE_DPF_EVAL_WALK_HPP__