Skip to content

Commit ed3fbfc

Browse files
author
AztecBot
committed
Merge branch 'v5-next' into merge-train/fairies-v5
2 parents 579be59 + c54416a commit ed3fbfc

3 files changed

Lines changed: 186 additions & 59 deletions

File tree

.test_patterns.yml

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -410,11 +410,6 @@ tests:
410410
owners:
411411
- *martin
412412

413-
- regex: "yarn-project/scripts/run_test.sh bb-prover/src/avm_proving_tests/avm_"
414-
error_regex: "timeout: sending signal"
415-
owners:
416-
- *charlie
417-
418413
- regex: "run_test.sh simple tx_stats_bench"
419414
error_regex: "✕ verifies transactions at 10 TPS"
420415
owners:

barretenberg/cpp/src/barretenberg/vm2/tracegen/trace_container.cpp

Lines changed: 96 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
#include "barretenberg/vm2/tracegen/trace_container.hpp"
22

33
#include <algorithm>
4-
#include <mutex>
4+
#include <ranges>
55

6+
#include "barretenberg/common/assert.hpp"
67
#include "barretenberg/common/log.hpp"
78
#include "barretenberg/common/ref_vector.hpp"
89
#include "barretenberg/vm2/common/field.hpp"
@@ -23,9 +24,15 @@ TraceContainer::TraceContainer()
2324
const FF& TraceContainer::get(Column col, uint32_t row) const
2425
{
2526
auto& column_data = (*trace)[static_cast<size_t>(col)];
26-
std::shared_lock lock(column_data.mutex);
27-
const auto it = column_data.rows.find(row);
28-
return it == column_data.rows.end() ? zero : it->second;
27+
const size_t shard_idx = row / INTERVAL_SIZE;
28+
if (shard_idx >= NUM_SHARDS) {
29+
return zero;
30+
}
31+
ColumnInterval* shard = column_data.slots[shard_idx].load(std::memory_order_acquire);
32+
if (shard == nullptr) {
33+
return zero;
34+
}
35+
return shard->rows[row % INTERVAL_SIZE];
2936
}
3037

3138
const FF& TraceContainer::get_column_or_shift(ColumnAndShifts col, uint32_t row) const
@@ -36,19 +43,42 @@ const FF& TraceContainer::get_column_or_shift(ColumnAndShifts col, uint32_t row)
3643
return get(static_cast<Column>(col), row);
3744
}
3845

46+
TraceContainer::ColumnInterval& TraceContainer::get_or_create_shard(SparseColumn& column_data, size_t shard_idx)
47+
{
48+
ColumnInterval* shard = column_data.slots[shard_idx].load(std::memory_order_acquire);
49+
if (shard != nullptr) {
50+
return *shard;
51+
}
52+
// Slow path: this slot has never been written. Construct a shard and install it with a single CAS.
53+
// Creators of different shards target different atomics and run fully in parallel with no lock. If we
54+
// lose the race for this slot (only possible when two chunks share a shard at a boundary), we discard
55+
// our spare copy and use the winner's.
56+
auto fresh = std::make_unique<ColumnInterval>();
57+
ColumnInterval* expected = nullptr;
58+
if (column_data.slots[shard_idx].compare_exchange_strong(
59+
expected, fresh.get(), std::memory_order_acq_rel, std::memory_order_acquire)) {
60+
return *fresh.release();
61+
}
62+
return *expected; // CAS failure loaded the winning pointer into `expected` (acquire).
63+
}
64+
3965
void TraceContainer::set(Column col, uint32_t row, const FF& value)
4066
{
4167
auto& column_data = (*trace)[static_cast<size_t>(col)];
42-
std::unique_lock lock(column_data.mutex);
68+
const size_t shard_idx = row / INTERVAL_SIZE;
69+
BB_ASSERT_LT(shard_idx, NUM_SHARDS, "row exceeds the maximum trace size");
70+
const uint32_t offset = row % INTERVAL_SIZE;
71+
4372
if (!value.is_zero()) {
44-
column_data.rows.insert_or_assign(row, value);
45-
column_data.max_row_number = std::max(column_data.max_row_number, static_cast<int64_t>(row));
73+
// Lock-free: a single atomic load finds the shard (created on first write), then we write our
74+
// own dense cell directly. Different rows are distinct array elements, so concurrent writers of
75+
// this column (or even of the same shard, at a chunk boundary) never race and never serialize.
76+
get_or_create_shard(column_data, shard_idx).rows[offset] = value;
4677
} else {
47-
auto num_erased = column_data.rows.erase(row);
48-
if (column_data.max_row_number == row && num_erased > 0) {
49-
// This shouldn't happen often. We delay recalculation of the max row number
50-
// until someone actually needs it.
51-
column_data.row_number_dirty = true;
78+
// Zero value: clear if present. We never create a shard (clearing an absent row is a no-op).
79+
ColumnInterval* shard = column_data.slots[shard_idx].load(std::memory_order_acquire);
80+
if (shard != nullptr) {
81+
shard->rows[offset] = FF::zero();
5282
}
5383
}
5484
}
@@ -62,24 +92,39 @@ void TraceContainer::set(uint32_t row, std::span<const std::pair<Column, FF>> va
6292

6393
void TraceContainer::reserve_column(Column col, size_t size)
6494
{
95+
if (size == 0) {
96+
return;
97+
}
6598
auto& column_data = (*trace)[static_cast<size_t>(col)];
66-
std::unique_lock lock(column_data.mutex);
67-
column_data.rows.reserve(size);
99+
const size_t num_shards = std::min((size + INTERVAL_SIZE - 1) / INTERVAL_SIZE, NUM_SHARDS);
100+
// Each shard's dense row array is full size on creation, so reserving just materializes the shards up
101+
// front (e.g. for precomputed columns). Lock-free: get_or_create_shard installs each via CAS.
102+
for (size_t k = 0; k < num_shards; ++k) {
103+
get_or_create_shard(column_data, k);
104+
}
68105
}
69106

70107
uint32_t TraceContainer::get_column_rows(Column col) const
71108
{
109+
// The number of rows is (highest non-zero absolute row + 1). We find it by scanning shards from the
110+
// top down and, within the first non-empty shard, scanning its rows from the top. Shards are
111+
// top-dense, so this terminates almost immediately. This is only called after the parallel fill
112+
// phase, so no lock is needed. Lower shards cannot hold a higher row, so the first hit is the answer.
72113
auto& column_data = (*trace)[static_cast<size_t>(col)];
73-
std::unique_lock lock(column_data.mutex);
74-
if (column_data.row_number_dirty) {
75-
// Trigger recalculation of max row number.
76-
auto keys = std::views::keys(column_data.rows);
77-
const auto it = std::ranges::max_element(keys);
78-
// We use -1 to indicate that the column is empty.
79-
column_data.max_row_number = it == keys.end() ? -1 : static_cast<int64_t>(*it);
80-
column_data.row_number_dirty = false;
81-
}
82-
return static_cast<uint32_t>(column_data.max_row_number + 1);
114+
for (size_t k = NUM_SHARDS; k-- > 0;) {
115+
ColumnInterval* shard_ptr = column_data.slots[k].load(std::memory_order_acquire);
116+
if (shard_ptr == nullptr) {
117+
continue;
118+
}
119+
const auto& rows = shard_ptr->rows;
120+
const uint32_t base = static_cast<uint32_t>(k) * INTERVAL_SIZE;
121+
for (uint32_t off = INTERVAL_SIZE; off-- > 0;) {
122+
if (!rows[off].is_zero()) {
123+
return base + off + 1;
124+
}
125+
}
126+
}
127+
return 0;
83128
}
84129

85130
uint32_t TraceContainer::get_num_witness_rows() const
@@ -103,9 +148,18 @@ uint32_t TraceContainer::get_num_rows() const
103148
void TraceContainer::visit_column(Column col, const std::function<void(uint32_t, const FF&)>& visitor) const
104149
{
105150
auto& column_data = (*trace)[static_cast<size_t>(col)];
106-
std::shared_lock lock(column_data.mutex);
107-
for (const auto& [row, value] : column_data.rows) {
108-
visitor(row, value);
151+
for (size_t k = 0; k < NUM_SHARDS; ++k) {
152+
ColumnInterval* shard_ptr = column_data.slots[k].load(std::memory_order_acquire);
153+
if (shard_ptr == nullptr) {
154+
continue;
155+
}
156+
auto& shard = *shard_ptr;
157+
const uint32_t base = static_cast<uint32_t>(k) * INTERVAL_SIZE;
158+
for (uint32_t off = 0; off < INTERVAL_SIZE; ++off) {
159+
if (!shard.rows[off].is_zero()) {
160+
visitor(base + off, shard.rows[off]);
161+
}
162+
}
109163
}
110164
}
111165

@@ -120,20 +174,28 @@ void TraceContainer::invert_column(Column col)
120174
{
121175
RefVector<FF> ff_vector;
122176
auto& column_data = (*trace)[static_cast<size_t>(col)];
123-
std::unique_lock lock(column_data.mutex);
124-
for (auto& [row, value] : column_data.rows) {
125-
ff_vector.push_back(value);
177+
for (size_t k = 0; k < NUM_SHARDS; ++k) {
178+
ColumnInterval* shard_ptr = column_data.slots[k].load(std::memory_order_acquire);
179+
if (shard_ptr == nullptr) {
180+
continue;
181+
}
182+
auto& shard = *shard_ptr;
183+
for (auto& value : shard.rows) {
184+
if (!value.is_zero()) {
185+
ff_vector.push_back(value);
186+
}
187+
}
126188
}
127189
FF::batch_invert<RefVector<FF>>(ff_vector);
128190
}
129191

130192
void TraceContainer::clear_column(Column col)
131193
{
194+
// Lock-free: exchange hands each non-null shard pointer to exactly one caller, which frees it.
132195
auto& column_data = (*trace)[static_cast<size_t>(col)];
133-
std::unique_lock lock(column_data.mutex);
134-
column_data.rows.clear();
135-
column_data.max_row_number = -1;
136-
column_data.row_number_dirty = false;
196+
for (size_t k = 0; k < NUM_SHARDS; ++k) {
197+
delete column_data.slots[k].exchange(nullptr, std::memory_order_acq_rel);
198+
}
137199
}
138200

139201
} // namespace bb::avm2::tracegen

barretenberg/cpp/src/barretenberg/vm2/tracegen/trace_container.hpp

Lines changed: 90 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,17 @@
22

33
#include <algorithm>
44
#include <array>
5+
#include <atomic>
56
#include <cstddef>
7+
#include <cstdint>
68
#include <functional>
79
#include <memory>
8-
#include <shared_mutex>
910
#include <span>
10-
#include <unordered_map>
1111
#include <utility>
12+
#include <vector>
1213

1314
#include "barretenberg/common/tuple.hpp"
15+
#include "barretenberg/vm2/common/constants.hpp"
1416
#include "barretenberg/vm2/common/field.hpp"
1517
#include "barretenberg/vm2/common/map.hpp"
1618
#include "barretenberg/vm2/constraining/flavor_settings.hpp"
@@ -19,10 +21,48 @@
1921

2022
namespace bb::avm2::tracegen {
2123

22-
// This container is thread-safe.
23-
// Contention can only happen when concurrently accessing the same column.
24+
// Thread-safety contract.
25+
//
26+
// This container supports concurrent get/set during the parallel tracegen fill phase, *as long as the
27+
// caller never lets two threads touch the same cell (column, row) with at least one of them writing*.
28+
// Under that contract the following are all race-free and never serialize against each other:
29+
// - writes to different columns (independent storage);
30+
// - writes to different rows of the same column, even when those rows fall in the same shard (e.g.
31+
// two chunks that meet at a shard boundary) — distinct rows are distinct objects;
32+
// - concurrent first writes that race to create the same shard — the shard is installed with a single
33+
// compare-and-swap, so it is created at most once (the loser frees its spare copy).
34+
//
35+
// What is NOT protected, and what the caller must therefore guarantee never happens concurrently:
36+
// - two writes to the same (column, row), or a read and a write of the same (column, row). There is
37+
// no per-cell synchronization, so this is a data race (undefined behavior). Tracegen satisfies the
38+
// contract because it never writes a given cell from two threads, and every read of a column
39+
// happens in a later, barrier-separated phase.
40+
// - get/set concurrent with reserve_column, clear_column, invert_column, or the destructor on the
41+
// same column. Those operations mutate or free the shard table and assume the parallel fill phase
42+
// for that column has already joined.
43+
//
44+
// Design. Each column is partitioned into fixed-size row intervals ("shards") of INTERVAL_SIZE rows.
45+
// The per-column shard table is a fixed-size array of atomic pointers indexed by shard (row /
46+
// INTERVAL_SIZE), so the hot get/set path finds its shard with a single lock-free atomic load; that
47+
// path takes no per-column or per-shard lock, so concurrent writers of a column never serialize. A
48+
// shard is created at most once with a lock-free compare-and-swap: a writer that finds a null slot
49+
// constructs a shard and atomically installs it only if the slot is still null, so creators of
50+
// different shards never serialize. Each shard holds its rows in a dense, fixed-size array, so once a
51+
// shard exists, a write is a plain store to a fixed address.
2452
class TraceContainer {
2553
public:
54+
// Number of rows owned by a single shard, and the unit of lazy allocation. Writes to different rows
55+
// never serialize regardless of shard, so this does not affect write parallelism; it only sets how
56+
// finely columns are allocated and the (rare) granularity at which two chunks can race to create the
57+
// same shard. Chunked tracegen sizes its chunks as a multiple of this value (and ideally aligns to
58+
// it) so that concurrent chunks touch disjoint shards. Smaller => less memory wasted in
59+
// sparsely-filled regions, at the cost of a larger shard table (more atomic slots) and more shard
60+
// allocations per column.
61+
static constexpr uint32_t INTERVAL_SIZE = 1u << 11;
62+
// Number of shards in a column's (fixed-size) shard table. The trace never exceeds the circuit size,
63+
// so this bounds the shard index. INTERVAL_SIZE divides MAX_AVM_TRACE_SIZE evenly.
64+
static constexpr size_t NUM_SHARDS = MAX_AVM_TRACE_SIZE / INTERVAL_SIZE;
65+
2666
TraceContainer();
2767

2868
const FF& get(Column col, uint32_t row) const;
@@ -42,7 +82,7 @@ class TraceContainer {
4282
// Reserve column size. Useful for precomputed columns.
4383
void reserve_column(Column col, size_t size);
4484

45-
// Visits non-zero values in a column.
85+
// Visits non-zero values in a column. The visit order is unspecified.
4686
void visit_column(Column col, const std::function<void(uint32_t, const FF&)>& visitor) const;
4787
// Returns the number of rows in a column. That is, the maximum non-zero row index + 1.
4888
uint32_t get_column_rows(Column col) const;
@@ -53,30 +93,60 @@ class TraceContainer {
5393
// Number of columns (without shifts).
5494
static constexpr size_t num_columns() { return NUM_COLUMNS_WITHOUT_SHIFTS; }
5595

56-
// Batch inverts a set of columns.
96+
// Batch inverts a set of columns. Not thread-safe.
5797
void invert_columns(std::span<const Column> cols);
5898

59-
// Free column memory.
99+
// Free column memory. Not thread-safe.
60100
void clear_column(Column col);
61101

62102
private:
63-
// We use a mutex per column to allow for concurrent writes.
64-
// Observe that therefore concurrent write access to different columns is cheap.
103+
// A contiguous range of rows [k*INTERVAL_SIZE, (k+1)*INTERVAL_SIZE) within a column.
104+
// Rows are stored densely: rows[row % INTERVAL_SIZE] holds the value for that absolute row,
105+
// with zero meaning "absent" (an unset cell reads as zero anyway). Shards are top-dense — once a
106+
// shard exists it tends to fill — so a flat array beats a hash map: no hashing/rehashing, sequential
107+
// cache-friendly writes, stable element addresses, and less memory than the map's key + load-factor
108+
// overhead.
109+
//
110+
// Concurrency: there is no per-shard lock. The array is fixed-size and never reallocates, so
111+
// concurrent writes to different rows of this shard touch distinct objects and are race-free by the
112+
// C++ memory model, including when two chunks meet at a shard boundary. Same-cell concurrency is the
113+
// caller's responsibility (see the thread-safety contract on TraceContainer). The row count is derived
114+
// lazily by scanning (see get_column_rows), avoiding any per-write bookkeeping.
115+
//
116+
// NOTE: the {} initializer on rows is required. FF's default constructor is trivial, so it zeroes
117+
// only under value-initialization; a plain `std::array<FF, INTERVAL_SIZE> rows;` would leave garbage,
118+
// breaking the "unset cell reads as zero" invariant.
119+
struct ColumnInterval {
120+
std::array<FF, INTERVAL_SIZE> rows{};
121+
};
122+
// A column is a fixed-size table of lazily-created shards (index = row / INTERVAL_SIZE), held inline
123+
// as atomic pointers. The hot get/set path reads a slot with a single relaxed/acquire atomic load and
124+
// never takes a lock, so concurrent writers of the same column do not serialize. A shard is created
125+
// at most once: a writer that finds a null slot constructs one and installs it with a compare-and-swap,
126+
// keeping it only if the slot was still null. Slots are never cleared concurrently with get/set
127+
// (clear_column and the destructor run after the parallel fill phase).
65128
struct SparseColumn {
66-
std::shared_mutex mutex;
67-
int64_t max_row_number = -1; // We use -1 to indicate that the column is empty.
68-
bool row_number_dirty; // Needs recalculation.
69-
// Future memory optimization notes: we can do the same trick as in Operand.
70-
// That is, store a variant with a unique_ptr. However, we should benchmark this.
71-
// (see serialization.hpp).
72-
unordered_flat_map<uint32_t, FF> rows;
129+
SparseColumn() = default;
130+
~SparseColumn()
131+
{
132+
for (size_t k = 0; k < NUM_SHARDS; ++k) {
133+
delete slots[k].load(std::memory_order_relaxed);
134+
}
135+
}
136+
SparseColumn(const SparseColumn&) = delete;
137+
SparseColumn& operator=(const SparseColumn&) = delete;
138+
139+
// The {} zero-initializes every slot to nullptr (std::atomic value-initializes its value in C++20).
140+
std::array<std::atomic<ColumnInterval*>, NUM_SHARDS> slots{};
73141
};
74-
// We store the trace as a sparse matrix.
75-
// We use a unique_ptr to allocate the array in the heap vs the stack.
76-
// Even if the _content_ of each unordered_map is always heap-allocated, if we have 3k columns
77-
// we could unnecessarily put strain on the stack with sizeof(unordered_map) * 3k bytes.
142+
// We store the trace as a sparse matrix. Each SparseColumn holds its shard table inline, so
143+
// sizeof(SparseColumn) is ~NUM_SHARDS atomic pointers; with ~3k columns the whole array is tens of
144+
// MB, so we heap-allocate it via unique_ptr rather than placing it on the stack.
78145
std::unique_ptr<std::array<SparseColumn, NUM_COLUMNS_WITHOUT_SHIFTS>> trace;
79146

147+
// Returns the shard owning shard_idx, creating it (once, via a lock-free compare-and-swap) if absent.
148+
static ColumnInterval& get_or_create_shard(SparseColumn& column_data, size_t shard_idx);
149+
// Not thread-safe.
80150
void invert_column(Column col);
81151
};
82152

0 commit comments

Comments
 (0)