Part 3 - Gatery and The C++ Meta Level
Building Flexible, Reusable Components.
A central aspect of Gatery is to facilitate the building of flexible and reusable components. Entire books have been written on how to achieve the same for software development and hence a single tutorial part will not be able to cover “all there is”. Instead, this part will look at a single, simple component and discuss two important concepts:
- how to use C++ as a meta level in which to describe the construction of the logic and
- that syntax matters and that C++ offers a range of possibilities for designing nice interfaces.
Construction Time vs On-Chip
The last tutorial part discussed how to construct basic logic from elementary signals.
Everything done with Gatery signals (Bit
, UInt
, …) and Gatery “control flow” (IF
, ELSE
) ends up in the RTL design and will perform computations in the FPGA/chip (unless they are optimized away).
But above this, there is an entire level of C++ variables and constructs that is not resulting in logic, at least not directly.
Everything involving C++ variables (int, float, std::vector, …) or C++ control flow (if, else, for, function-calls, …) happens at construction time, when the generator program is running.
Evaluated at construction-time | Evaluated on chip |
---|---|
if, else | IF, ELSE |
switch | mux(…) |
for, goto, function-calls | |
Operations on C++ variables (int, float, std::string, …) | Operations involving at least one UInt or Bit |
Setting the width of a UInt |
Unlike in High-Level Synthesis, no behavior for the RTL-design is inferred from the C++ control flow and variables. The latter does, however, control the generation of the RTL-design. Constants can be computed from configuration parameters, bit widths can be adjusted, components enabled or disabled. In essence, regular C++ is the meta-programming level of gatery.
What preprocessor macros are to C, what templates are to C++, and what generics are to VHDL, the entire expressive power of C++ is to gatery. And from this arises an important difference between hardware construction languages like gatery and hardware description languages like VHDL. The goal of gatery is not to write a hardware description like in VHDL but with different syntax. Instead, the goal is to write algorithms, programs in fact, that compose the hardware programmatically based on configuration options, where the configuration options are much more diverse than simple bit widths.
While the full power of this really shines with large designs, we want to demonstrate this on a small toy example: a carry safe adder.
What is a carry safe adder
A carry safe adder is a special type of adder for more than two summands. The naive way of adding multiple summands is to add them one after another, each time again resolving the carry chain. This leads to unpleasantly long signal paths. The idea of the carry safe adder is to not resolve the carry chain for each individual addition. Each carry bit is not fed into the adder of the next bit, but into the adder of the next bit of the next addition.Carry Safe Adder
Let us start sketching the logic without any regard for structure for now. The input is an array of summands.
// Assume resized and all of equal width
std::vector<UInt> summands = ....;
The carry safe adder consists essentially of many full adders, each ingesting the running sum, the running carry, and the next summand and outputting a new sum and a new carry.
Thus we need two UInt
s, one to accumulate the sum and one for the carries.
The final step is to resolve the carry chain by adding the carries to the sum.
// Assume resized and all of equal width
std::vector<UInt> summands = ....;
UInt sum;
UInt carry;
// magic
UInt result = sum + carry;
Note that if only two summands exist, the carry safe adder doesn’t make much sense and we can directly use the normal addition. What’s more, if only one summand exists, no additions need to be performed at all.
// Assume resized and all of equal width
std::vector<UInt> summands = ....;
UInt result;
if (summands.size() == 1) {
result = summands[0];
} else if (summands.size() >= 1) {
UInt sum = summands[0];
UInt carry = summands[1];
// magic
result = sum + carry;
}
To add the third summand, we need to implement the actual full adder. Note how we exploit the mutable nature of gatery signals to update the existing sum and carries.
// Assume resized and all of equal width
std::vector<UInt> summands = ....;
UInt result;
if (summands.size() == 1) {
result = summands[0];
} else if (summands.size() >= 1) {
UInt sum = summands[0];
UInt carry = summands[1];
// One full adder step to add summands[2]
UInt new_carry = (sum & carry) | (sum & summands[2]) | (m_carry & summands[2]);
sum ^= carry ^ summands[2];
carry = new_carry << 1;
result = sum + carry;
}
This only adds the first three summands. Luckily for the additional summands, we need to do exactly the same which, thanks to the mutable signals, can be easily done in a loop.
// Assume resized and all of equal width
std::vector<UInt> summands = ....;
UInt result;
if (summands.size() == 1) {
result = summands[0];
} else if (summands.size() >= 1) {
UInt sum = summands[0];
UInt carry = summands[1];
// One full adder step per additional summand
for (unsigned i = 2; i < summands.size(); i++) {
UInt new_carry = (sum & carry) | (sum & summands[i]) | (carry & summands[i]);
sum ^= carry ^ summands[i];
carry = new_carry << 1;
}
result = sum + carry;
}
We now have a carry safe adder. The Right Thing To Do now would be to test it. However, we will cover simulation and testing in a later tutorial part (after we have covered clocks and registers), so let’s not get ahead of ourselves. Instead, we want to structure it more nicely.
Syntax Matters
We want to encapsulate the details of the adder and make it easy to (re)use.
We also don’t want to force the user to arrange all the summands into an std::vector
.
We move the UInt
s into a class and refactor the code into individual member functions.
class CarrySafeAdder {
public:
void add(const UInt &b) {
if (m_count == 0)
m_sum = b;
else if (m_count == 1)
m_carry = b;
else {
UInt new_carry = (m_sum & m_carry) | (m_sum & b) | (m_carry & b);
m_sum ^= m_carry ^ b;
m_carry = new_carry << 1;
}
m_count++;
}
UInt sum() const {
if (m_count <= 1)
return m_sum;
return m_sum + m_carry;
}
protected:
unsigned m_count = 0;
UInt m_carry;
UInt m_sum;
};
// Assume resized and all of equal width
std::vector<UInt> summands = ....;
// Just to demonstrate the usage, tying in here with the previous std::vector
CarrySafeAdder adder;
for (const auto &b : summands)
adder.add(b);
UInt result = adder.sum();
Since C++ allows operator overloading, we can make it even nicer and additionally bind those methods to the add and cast operators.
class CarrySafeAdder {
public:
void add(const UInt &b) {
// snip
}
UInt sum() const {
// snip
}
CarrySafeAdder operator + (const UInt& b) { CarrySafeAdder ret = *this; ret.add(b); return ret; }
CarrySafeAdder& operator += (const UInt& b) { add(b); return *this; }
operator UInt () const { return sum(); }
protected:
unsigned m_count = 0;
UInt m_carry;
UInt m_sum;
};
int main() {
// ...
// Assume resized and all of equal width
std::vector<UInt> summands = ....;
// Just to demonstrate the usage, tying in here with the previous std::vector
CarrySafeAdder adder;
for (const auto &b : summands)
adder += b;
UInt result = adder;
With these additions, using the carry safe adder can be as simple as
UInt a, b, c, d;
a = ...;
b = ...;
c = ...;
d = ...;
UInt result = CarrySafeAdder{} + a + b + c + d;
And there you have it. A carry safe adder that adapts seamlessly to different bit widths and numbers of summands. In designs that use the carry safe adder, the entire invocation is a simple line of additions that immediately communicates to the reader the desired intent.
This particular design is actually part of the standard component library of gatery (more on that later), so it can be just used, freely, and without reimplementing it.
Conclusion
Gatery allows building components that programmatically react to configurations and settings at construction time, much like generics e.g. in VHDL. By using C++ for this meta level, every aspect of C++ can be used to this end giving much more flexibility than generics ever could.
Secondly, C++ offers a host of options for building short, concise interfaces. No more pages-long lists of entity port maps. Simple one line interfaces can be designed, which are not only quicker to write fostering reuse, they also communicate much more quickly and intuitively the intent to readers of the code.
The next part of this series will show how to export a gatery design to VHDL so it can be synthesized e.g. for an FPGA.