8. Scheduling multi-stage pipelines#
// Halide tutorial lesson 8: Scheduling multi-stage pipelines
// On linux, you can compile and run it like so:
// g++ lesson_08*.cpp -g -std=c++17 -I <path/to/include> -L <path/to/lib> -lHalide -lpthread -ldl -o lesson_08
// LD_LIBRARY_PATH=<path/to/lib> ./lesson_08
// On macOS:
// g++ lesson_08*.cpp -g -std=c++17 -I <path/to/include> -L <path/to/lib> -lHalide -o lesson_08
// DYLD_LIBRARY_PATH=<path/to/lib> ./lesson_08
#include "Halide.h"
#include <cstdio>
using namespace Halide;
int main() {
// First we'll declare some Vars to use below.
Var x("x"), y("y");
// Let's examine various scheduling options for a simple two stage
// pipeline. We'll start with the default schedule:
{
Func producer("producer_default"), consumer("consumer_default");
// The first stage will be some simple pointwise math similar
// to our familiar gradient function. The value at position x,
// y is the sin of product of x and y.
producer(x, y) = sin(x * y);
// Now we'll add a second stage which averages together multiple
// points in the first stage.
consumer(x, y) = (producer(x, y) +
producer(x, y + 1) +
producer(x + 1, y) +
producer(x + 1, y + 1)) / 4;
// We'll turn on tracing for both functions.
consumer.trace_stores();
producer.trace_stores();
// And evaluate it over a 4x4 box.
printf("\nEvaluating producer-consumer pipeline with default schedule\n");
consumer.realize({4, 4});
Show output
Begin pipeline consumer_default.0() Tag consumer_default.0() tag = "func_type_and_dim: 1 2 32 1 2 0 4 0 4" Store consumer_default.0(0, 0) = 0.210368 Store consumer_default.0(1, 0) = 0.437692 Store consumer_default.0(2, 0) = 0.262604 Store consumer_default.0(3, 0) = -0.153921 Store consumer_default.0(0, 1) = 0.437692 Store consumer_default.0(1, 1) = 0.475816 Store consumer_default.0(2, 1) = 0.003550 Store consumer_default.0(3, 1) = 0.023565 Store consumer_default.0(0, 2) = 0.262604 Store consumer_default.0(1, 2) = 0.003550 Store consumer_default.0(2, 2) = -0.225879 Store consumer_default.0(3, 2) = 0.146372 Store consumer_default.0(0, 3) = -0.153921 Store consumer_default.0(1, 3) = 0.023565 Store consumer_default.0(2, 3) = 0.146372 Store consumer_default.0(3, 3) = -0.237233 End pipeline consumer_default.0()
// There were no messages about computing values of the
// producer. This is because the default schedule fully
// inlines 'producer' into 'consumer'. It is as if we had
// written the following code instead:
// consumer(x, y) = (sin(x * y) +
// sin(x * (y + 1)) +
// sin((x + 1) * y) +
// sin((x + 1) * (y + 1))/4);
// All calls to 'producer' have been replaced with the body of
// 'producer', with the arguments substituted in for the
// variables.
// The equivalent C code is:
float result[4][4];
for (int y = 0; y < 4; y++) {
for (int x = 0; x < 4; x++) {
result[y][x] = (sin(x * y) +
sin(x * (y + 1)) +
sin((x + 1) * y) +
sin((x + 1) * (y + 1))) / 4;
}
}
printf("\n");
// If we look at the loop nest, the producer doesn't appear
// at all. It has been inlined into the consumer.
printf("Pseudo-code for the schedule:\n");
consumer.print_loop_nest();
Show output
produce consumer_default:
for y:
for x:
consumer_default(...) = ...
printf("\n");
}
// Next we'll examine the next simplest option - computing all
// values required in the producer before computing any of the
// consumer. We call this schedule "root".
{
// Start with the same function definitions:
Func producer("producer_root"), consumer("consumer_root");
producer(x, y) = sin(x * y);
consumer(x, y) = (producer(x, y) +
producer(x, y + 1) +
producer(x + 1, y) +
producer(x + 1, y + 1)) / 4;
// Tell Halide to evaluate all of producer before any of consumer.
producer.compute_root();
// Turn on tracing.
consumer.trace_stores();
producer.trace_stores();
// Compile and run.
printf("\nEvaluating producer.compute_root()\n");
consumer.realize({4, 4});
Show output
Begin pipeline consumer_root.0() Tag producer_root.0() tag = "func_type_and_dim: 1 2 32 1 2 0 5 0 5" Tag consumer_root.0() tag = "func_type_and_dim: 1 2 32 1 2 0 4 0 4" Store producer_root.0(0, 0) = 0.000000 Store producer_root.0(1, 0) = 0.000000 Store producer_root.0(2, 0) = 0.000000 Store producer_root.0(3, 0) = 0.000000 Store producer_root.0(4, 0) = 0.000000 Store producer_root.0(0, 1) = 0.000000 Store producer_root.0(1, 1) = 0.841471 Store producer_root.0(2, 1) = 0.909297 Store producer_root.0(3, 1) = 0.141120 Store producer_root.0(4, 1) = -0.756802 Store producer_root.0(0, 2) = 0.000000 Store producer_root.0(1, 2) = 0.909297 Store producer_root.0(2, 2) = -0.756802 Store producer_root.0(3, 2) = -0.279415 Store producer_root.0(4, 2) = 0.989358 Store producer_root.0(0, 3) = 0.000000 Store producer_root.0(1, 3) = 0.141120 Store producer_root.0(2, 3) = -0.279415 Store producer_root.0(3, 3) = 0.412118 Store producer_root.0(4, 3) = -0.536573 Store producer_root.0(0, 4) = 0.000000 Store producer_root.0(1, 4) = -0.756802 Store producer_root.0(2, 4) = 0.989358 Store producer_root.0(3, 4) = -0.536573 Store producer_root.0(4, 4) = -0.287903 Store consumer_root.0(0, 0) = 0.210368 Store consumer_root.0(1, 0) = 0.437692 Store consumer_root.0(2, 0) = 0.262604 Store consumer_root.0(3, 0) = -0.153921 Store consumer_root.0(0, 1) = 0.437692 Store consumer_root.0(1, 1) = 0.475816 Store consumer_root.0(2, 1) = 0.003550 Store consumer_root.0(3, 1) = 0.023565 Store consumer_root.0(0, 2) = 0.262604 Store consumer_root.0(1, 2) = 0.003550 Store consumer_root.0(2, 2) = -0.225879 Store consumer_root.0(3, 2) = 0.146372 Store consumer_root.0(0, 3) = -0.153921 Store consumer_root.0(1, 3) = 0.023565 Store consumer_root.0(2, 3) = 0.146372 Store consumer_root.0(3, 3) = -0.237233 End pipeline consumer_root.0()
// Reading the output we can see that:
// A) There were stores to producer.
// B) They all happened before any stores to consumer.
// See below for a visualization.
// The producer is on the left and the consumer is on the
// right. Stores are marked in orange and loads are marked in
// blue.
// Equivalent C:
float result[4][4];
// Allocate some temporary storage for the producer.
float producer_storage[5][5];
// Compute the producer.
for (int y = 0; y < 5; y++) {
for (int x = 0; x < 5; x++) {
producer_storage[y][x] = sin(x * y);
}
}
// Compute the consumer. Skip the prints this time.
for (int y = 0; y < 4; y++) {
for (int x = 0; x < 4; x++) {
result[y][x] = (producer_storage[y][x] +
producer_storage[y + 1][x] +
producer_storage[y][x + 1] +
producer_storage[y + 1][x + 1]) / 4;
}
}
// Note that consumer was evaluated over a 4x4 box, so Halide
// automatically inferred that producer was needed over a 5x5
// box. This is the same 'bounds inference' logic we saw in
// the previous lesson, where it was used to detect and avoid
// out-of-bounds reads from an input image.
// If we print the loop nest, we'll see something very
// similar to the C above.
printf("Pseudo-code for the schedule:\n");
consumer.print_loop_nest();
Show output
produce producer_root:
for y:
for x:
producer_root(...) = ...
consume producer_root:
produce consumer_root:
for y:
for x:
consumer_root(...) = ...
printf("\n");
}
// Let's compare the two approaches above from a performance
// perspective.
// Full inlining (the default schedule):
// - Temporary memory allocated: 0
// - Loads: 0
// - Stores: 16
// - Calls to sin: 64
// producer.compute_root():
// - Temporary memory allocated: 25 floats
// - Loads: 64
// - Stores: 41
// - Calls to sin: 25
// There's a trade-off here. Full inlining used minimal temporary
// memory and memory bandwidth, but did a whole bunch of redundant
// expensive math (calling sin). It evaluated most points in
// 'producer' four times. The second schedule,
// producer.compute_root(), did the minimum number of calls to
// sin, but used more temporary memory and more memory bandwidth.
// In any given situation the correct choice can be difficult to
// make. If you're memory-bandwidth limited, or don't have much
// memory (e.g. because you're running on an old cell-phone), then
// it can make sense to do redundant math. On the other hand, sin
// is expensive, so if you're compute-limited then fewer calls to
// sin will make your program faster. Adding vectorization or
// multi-core parallelism tilts the scales in favor of doing
// redundant work, because firing up multiple cpu cores increases
// the amount of math you can do per second, but doesn't increase
// your system memory bandwidth or capacity.
// We can make choices in between full inlining and
// compute_root. Next we'll alternate between computing the
// producer and consumer on a per-scanline basis:
{
// Start with the same function definitions:
Func producer("producer_y"), consumer("consumer_y");
producer(x, y) = sin(x * y);
consumer(x, y) = (producer(x, y) +
producer(x, y + 1) +
producer(x + 1, y) +
producer(x + 1, y + 1)) / 4;
// Tell Halide to evaluate producer as needed per y coordinate
// of the consumer:
producer.compute_at(consumer, y);
// This places the code that computes the producer just
// *inside* the consumer's for loop over y, as in the
// equivalent C below.
// Turn on tracing.
producer.trace_stores();
consumer.trace_stores();
// Compile and run.
printf("\nEvaluating producer.compute_at(consumer, y)\n");
consumer.realize({4, 4});
Show output
Begin pipeline consumer_y.0() Tag producer_y.0() tag = "func_type_and_dim: 1 2 32 1 2 0 5 0 5" Tag consumer_y.0() tag = "func_type_and_dim: 1 2 32 1 2 0 4 0 4" Store producer_y.0(0, 0) = 0.000000 Store producer_y.0(1, 0) = 0.000000 Store producer_y.0(2, 0) = 0.000000 Store producer_y.0(3, 0) = 0.000000 Store producer_y.0(4, 0) = 0.000000 Store producer_y.0(0, 1) = 0.000000 Store producer_y.0(1, 1) = 0.841471 Store producer_y.0(2, 1) = 0.909297 Store producer_y.0(3, 1) = 0.141120 Store producer_y.0(4, 1) = -0.756802 Store consumer_y.0(0, 0) = 0.210368 Store consumer_y.0(1, 0) = 0.437692 Store consumer_y.0(2, 0) = 0.262604 Store consumer_y.0(3, 0) = -0.153921 Store producer_y.0(0, 1) = 0.000000 Store producer_y.0(1, 1) = 0.841471 Store producer_y.0(2, 1) = 0.909297 Store producer_y.0(3, 1) = 0.141120 Store producer_y.0(4, 1) = -0.756802 Store producer_y.0(0, 2) = 0.000000 Store producer_y.0(1, 2) = 0.909297 Store producer_y.0(2, 2) = -0.756802 Store producer_y.0(3, 2) = -0.279415 Store producer_y.0(4, 2) = 0.989358 Store consumer_y.0(0, 1) = 0.437692 Store consumer_y.0(1, 1) = 0.475816 Store consumer_y.0(2, 1) = 0.003550 Store consumer_y.0(3, 1) = 0.023565 Store producer_y.0(0, 2) = 0.000000 Store producer_y.0(1, 2) = 0.909297 Store producer_y.0(2, 2) = -0.756802 Store producer_y.0(3, 2) = -0.279415 Store producer_y.0(4, 2) = 0.989358 Store producer_y.0(0, 3) = 0.000000 Store producer_y.0(1, 3) = 0.141120 Store producer_y.0(2, 3) = -0.279415 Store producer_y.0(3, 3) = 0.412118 Store producer_y.0(4, 3) = -0.536573 Store consumer_y.0(0, 2) = 0.262604 Store consumer_y.0(1, 2) = 0.003550 Store consumer_y.0(2, 2) = -0.225879 Store consumer_y.0(3, 2) = 0.146372 Store producer_y.0(0, 3) = 0.000000 Store producer_y.0(1, 3) = 0.141120 Store producer_y.0(2, 3) = -0.279415 Store producer_y.0(3, 3) = 0.412118 Store producer_y.0(4, 3) = -0.536573 Store producer_y.0(0, 4) = 0.000000 Store producer_y.0(1, 4) = -0.756802 Store producer_y.0(2, 4) = 0.989358 Store producer_y.0(3, 4) = -0.536573 Store producer_y.0(4, 4) = -0.287903 Store consumer_y.0(0, 3) = -0.153921 Store consumer_y.0(1, 3) = 0.023565 Store consumer_y.0(2, 3) = 0.146372 Store consumer_y.0(3, 3) = -0.237233 End pipeline consumer_y.0()
// See below for a visualization.
// Reading the log or looking at the figure you should see
// that producer and consumer alternate on a per-scanline
// basis. Let's look at the equivalent C:
float result[4][4];
// There's an outer loop over scanlines of consumer:
for (int y = 0; y < 4; y++) {
// Allocate space and compute enough of the producer to
// satisfy this single scanline of the consumer. This
// means a 5x2 box of the producer.
float producer_storage[2][5];
for (int py = y; py < y + 2; py++) {
for (int px = 0; px < 5; px++) {
producer_storage[py - y][px] = sin(px * py);
}
}
// Compute a scanline of the consumer.
for (int x = 0; x < 4; x++) {
result[y][x] = (producer_storage[0][x] +
producer_storage[1][x] +
producer_storage[0][x + 1] +
producer_storage[1][x + 1]) / 4;
}
}
// Again, if we print the loop nest, we'll see something very
// similar to the C above.
printf("Pseudo-code for the schedule:\n");
consumer.print_loop_nest();
Show output
produce consumer_y:
for y:
produce producer_y:
for y:
for x:
producer_y(...) = ...
consume producer_y:
for x:
consumer_y(...) = ...
printf("\n");
// The performance characteristics of this strategy are in
// between inlining and compute root. We still allocate some
// temporary memory, but less than compute_root, and with
// better locality (we load from it soon after writing to it,
// so for larger images, values should still be in cache). We
// still do some redundant work, but less than full inlining:
// producer.compute_at(consumer, y):
// - Temporary memory allocated: 10 floats
// - Loads: 64
// - Stores: 56
// - Calls to sin: 40
}
// We could also say producer.compute_at(consumer, x), but this
// would be very similar to full inlining (the default
// schedule). Instead let's distinguish between the loop level at
// which we allocate storage for producer, and the loop level at
// which we actually compute it. This unlocks a few optimizations.
{
Func producer("producer_root_y"), consumer("consumer_root_y");
producer(x, y) = sin(x * y);
consumer(x, y) = (producer(x, y) +
producer(x, y + 1) +
producer(x + 1, y) +
producer(x + 1, y + 1)) / 4;
// Tell Halide to make a buffer to store all of producer at
// the outermost level:
producer.store_root();
// ... but compute it as needed per y coordinate of the
// consumer.
producer.compute_at(consumer, y);
producer.trace_stores();
consumer.trace_stores();
printf("\nEvaluating producer.store_root().compute_at(consumer, y)\n");
consumer.realize({4, 4});
Show output
Begin pipeline consumer_root_y.0() Tag producer_root_y.0() tag = "func_type_and_dim: 1 2 32 1 2 0 5 0 5" Tag consumer_root_y.0() tag = "func_type_and_dim: 1 2 32 1 2 0 4 0 4" Store producer_root_y.0(0, 0) = 0.000000 Store producer_root_y.0(1, 0) = 0.000000 Store producer_root_y.0(2, 0) = 0.000000 Store producer_root_y.0(3, 0) = 0.000000 Store producer_root_y.0(4, 0) = 0.000000 Store producer_root_y.0(0, 1) = 0.000000 Store producer_root_y.0(1, 1) = 0.841471 Store producer_root_y.0(2, 1) = 0.909297 Store producer_root_y.0(3, 1) = 0.141120 Store producer_root_y.0(4, 1) = -0.756802 Store consumer_root_y.0(0, 0) = 0.210368 Store consumer_root_y.0(1, 0) = 0.437692 Store consumer_root_y.0(2, 0) = 0.262604 Store consumer_root_y.0(3, 0) = -0.153921 Store producer_root_y.0(0, 2) = 0.000000 Store producer_root_y.0(1, 2) = 0.909297 Store producer_root_y.0(2, 2) = -0.756802 Store producer_root_y.0(3, 2) = -0.279415 Store producer_root_y.0(4, 2) = 0.989358 Store consumer_root_y.0(0, 1) = 0.437692 Store consumer_root_y.0(1, 1) = 0.475816 Store consumer_root_y.0(2, 1) = 0.003550 Store consumer_root_y.0(3, 1) = 0.023565 Store producer_root_y.0(0, 3) = 0.000000 Store producer_root_y.0(1, 3) = 0.141120 Store producer_root_y.0(2, 3) = -0.279415 Store producer_root_y.0(3, 3) = 0.412118 Store producer_root_y.0(4, 3) = -0.536573 Store consumer_root_y.0(0, 2) = 0.262604 Store consumer_root_y.0(1, 2) = 0.003550 Store consumer_root_y.0(2, 2) = -0.225879 Store consumer_root_y.0(3, 2) = 0.146372 Store producer_root_y.0(0, 4) = 0.000000 Store producer_root_y.0(1, 4) = -0.756802 Store producer_root_y.0(2, 4) = 0.989358 Store producer_root_y.0(3, 4) = -0.536573 Store producer_root_y.0(4, 4) = -0.287903 Store consumer_root_y.0(0, 3) = -0.153921 Store consumer_root_y.0(1, 3) = 0.023565 Store consumer_root_y.0(2, 3) = 0.146372 Store consumer_root_y.0(3, 3) = -0.237233 End pipeline consumer_root_y.0()
// See below for a
// visualization.
// Reading the log or looking at the figure you should see
// that producer and consumer again alternate on a
// per-scanline basis. It computes a 5x2 box of the producer
// to satisfy the first scanline of the consumer, but after
// that it only computes a 5x1 box of the output for each new
// scanline of the consumer!
//
// Halide has detected that for all scanlines except for the
// first, it can reuse the values already sitting in the
// buffer we've allocated for producer. Let's look at the
// equivalent C:
float result[4][4];
// producer.store_root() implies that storage goes here:
float producer_storage[5][5];
// There's an outer loop over scanlines of consumer:
for (int y = 0; y < 4; y++) {
// Compute enough of the producer to satisfy this scanline
// of the consumer.
for (int py = y; py < y + 2; py++) {
// Skip over rows of producer that we've already
// computed in a previous iteration.
if (y > 0 && py == y) { continue; }
for (int px = 0; px < 5; px++) {
producer_storage[py][px] = sin(px * py);
}
}
// Compute a scanline of the consumer.
for (int x = 0; x < 4; x++) {
result[y][x] = (producer_storage[y][x] +
producer_storage[y + 1][x] +
producer_storage[y][x + 1] +
producer_storage[y + 1][x + 1]) / 4;
}
}
printf("Pseudo-code for the schedule:\n");
consumer.print_loop_nest();
Show output
store producer_root_y:
produce consumer_root_y:
for y.:
produce producer_root_y:
for y:
for x:
producer_root_y(...) = ...
consume producer_root_y:
for x:
consumer_root_y(...) = ...
printf("\n");
// The performance characteristics of this strategy are pretty
// good! The numbers are similar to compute_root, except locality
// is better. We're doing the minimum number of sin calls,
// and we load values soon after they are stored, so we're
// probably making good use of the cache:
// producer.store_root().compute_at(consumer, y):
// - Temporary memory allocated: 10 floats
// - Loads: 64
// - Stores: 41
// - Calls to sin: 25
// Note that my claimed amount of memory allocated doesn't
// match the reference C code. Halide is performing one more
// optimization under the hood. It folds the storage for the
// producer down into a circular buffer of two
// scanlines. Equivalent C would actually look like this:
{
// Actually store 2 scanlines instead of 5
float producer_storage[2][5];
for (int y = 0; y < 4; y++) {
for (int py = y; py < y + 2; py++) {
if (y > 0 && py == y) { continue; }
for (int px = 0; px < 5; px++) {
// Stores to producer_storage have their y coordinate bit-masked.
producer_storage[py & 1][px] = sin(px * py);
}
}
// Compute a scanline of the consumer.
for (int x = 0; x < 4; x++) {
// Loads from producer_storage have their y coordinate bit-masked.
result[y][x] = (producer_storage[y & 1][x] +
producer_storage[(y + 1) & 1][x] +
producer_storage[y & 1][x + 1] +
producer_storage[(y + 1) & 1][x + 1]) / 4;
}
}
}
}
// We can do even better, by leaving the storage in the outermost
// loop, but moving the computation into the innermost loop:
{
Func producer("producer_root_x"), consumer("consumer_root_x");
producer(x, y) = sin(x * y);
consumer(x, y) = (producer(x, y) +
producer(x, y + 1) +
producer(x + 1, y) +
producer(x + 1, y + 1)) / 4;
// Store outermost, compute innermost.
producer.store_root().compute_at(consumer, x);
producer.trace_stores();
consumer.trace_stores();
printf("\nEvaluating producer.store_root().compute_at(consumer, x)\n");
consumer.realize({4, 4});
Show output
Begin pipeline consumer_root_x.0() Tag producer_root_x.0() tag = "func_type_and_dim: 1 2 32 1 2 0 5 0 5" Tag consumer_root_x.0() tag = "func_type_and_dim: 1 2 32 1 2 0 4 0 4" Store producer_root_x.0(0, 0) = 0.000000 Store producer_root_x.0(1, 0) = 0.000000 Store producer_root_x.0(2, 0) = 0.000000 Store producer_root_x.0(3, 0) = 0.000000 Store producer_root_x.0(4, 0) = 0.000000 Store producer_root_x.0(0, 1) = 0.000000 Store producer_root_x.0(1, 1) = 0.841471 Store consumer_root_x.0(0, 0) = 0.210368 Store producer_root_x.0(2, 1) = 0.909297 Store consumer_root_x.0(1, 0) = 0.437692 Store producer_root_x.0(3, 1) = 0.141120 Store consumer_root_x.0(2, 0) = 0.262604 Store producer_root_x.0(4, 1) = -0.756802 Store consumer_root_x.0(3, 0) = -0.153921 Store producer_root_x.0(0, 2) = 0.000000 Store producer_root_x.0(1, 2) = 0.909297 Store consumer_root_x.0(0, 1) = 0.437692 Store producer_root_x.0(2, 2) = -0.756802 Store consumer_root_x.0(1, 1) = 0.475816 Store producer_root_x.0(3, 2) = -0.279415 Store consumer_root_x.0(2, 1) = 0.003550 Store producer_root_x.0(4, 2) = 0.989358 Store consumer_root_x.0(3, 1) = 0.023565 Store producer_root_x.0(0, 3) = 0.000000 Store producer_root_x.0(1, 3) = 0.141120 Store consumer_root_x.0(0, 2) = 0.262604 Store producer_root_x.0(2, 3) = -0.279415 Store consumer_root_x.0(1, 2) = 0.003550 Store producer_root_x.0(3, 3) = 0.412118 Store consumer_root_x.0(2, 2) = -0.225879 Store producer_root_x.0(4, 3) = -0.536573 Store consumer_root_x.0(3, 2) = 0.146372 Store producer_root_x.0(0, 4) = 0.000000 Store producer_root_x.0(1, 4) = -0.756802 Store consumer_root_x.0(0, 3) = -0.153921 Store producer_root_x.0(2, 4) = 0.989358 Store consumer_root_x.0(1, 3) = 0.023565 Store producer_root_x.0(3, 4) = -0.536573 Store consumer_root_x.0(2, 3) = 0.146372 Store producer_root_x.0(4, 4) = -0.287903 Store consumer_root_x.0(3, 3) = -0.237233 End pipeline consumer_root_x.0()
// See below for a
// visualization.
// You should see that producer and consumer now alternate on
// a per-pixel basis. Here's the equivalent C:
float result[4][4];
// producer.store_root() implies that storage goes here, but
// we can fold it down into a circular buffer of two
// scanlines:
float producer_storage[2][5];
// For every pixel of the consumer:
for (int y = 0; y < 4; y++) {
for (int x = 0; x < 4; x++) {
// Compute enough of the producer to satisfy this
// pixel of the consumer, but skip values that we've
// already computed:
if (y == 0 && x == 0) {
producer_storage[y & 1][x] = sin(x * y);
}
if (y == 0) {
producer_storage[y & 1][x + 1] = sin((x + 1) * y);
}
if (x == 0) {
producer_storage[(y + 1) & 1][x] = sin(x * (y + 1));
}
producer_storage[(y + 1) & 1][x + 1] = sin((x + 1) * (y + 1));
result[y][x] = (producer_storage[y & 1][x] +
producer_storage[(y + 1) & 1][x] +
producer_storage[y & 1][x + 1] +
producer_storage[(y + 1) & 1][x + 1]) / 4;
}
}
printf("Pseudo-code for the schedule:\n");
consumer.print_loop_nest();
Show output
store producer_root_x:
produce consumer_root_x:
for y.:
for x.:
produce producer_root_x:
for y:
for x:
producer_root_x(...) = ...
consume producer_root_x:
consumer_root_x(...) = ...
printf("\n");
// The performance characteristics of this strategy are the
// best so far. One of the four values of the producer we need
// is probably still sitting in a register, so I won't count
// it as a load:
// producer.store_root().compute_at(consumer, x):
// - Temporary memory allocated: 10 floats
// - Loads: 48
// - Stores: 41
// - Calls to sin: 25
}
// So what's the catch? Why not always do
// producer.store_root().compute_at(consumer, x) for this type of
// code?
//
// The answer is parallelism. In both of the previous two
// strategies we've assumed that values computed in previous
// iterations are lying around for us to reuse. This assumes that
// previous values of x or y happened earlier in time and have
// finished. This is not true if you parallelize or vectorize
// either loop. If you parallelize, Halide won't inject the
// optimizations that skip work already done if there's a parallel
// loop in between the store_at level and the compute_at level,
// and won't fold the storage down into a circular buffer either,
// which makes our store_root pointless.
// We're running out of options. We can make new ones by
// splitting. We can store_at or compute_at at the natural
// variables of the consumer (x and y), or we can split x or y
// into new inner and outer sub-variables and then schedule with
// respect to those. We'll use this to express fusion in tiles:
{
Func producer("producer_tile"), consumer("consumer_tile");
producer(x, y) = sin(x * y);
consumer(x, y) = (producer(x, y) +
producer(x, y + 1) +
producer(x + 1, y) +
producer(x + 1, y + 1)) / 4;
// We'll compute 8x8 of the consumer, in 4x4 tiles.
Var x_outer, y_outer, x_inner, y_inner;
consumer.tile(x, y, x_outer, y_outer, x_inner, y_inner, 4, 4);
// Compute the producer per tile of the consumer
producer.compute_at(consumer, x_outer);
// Notice that I wrote my schedule starting from the end of
// the pipeline (the consumer). This is because the schedule
// for the producer refers to x_outer, which we introduced
// when we tiled the consumer. You can write it in the other
// order, but it tends to be harder to read.
// Turn on tracing.
producer.trace_stores();
consumer.trace_stores();
printf("\nEvaluating:\n"
"consumer.tile(x, y, x_outer, y_outer, x_inner, y_inner, 4, 4);\n"
"producer.compute_at(consumer, x_outer);\n");
consumer.realize({8, 8});
Show output
Begin pipeline consumer_tile.0() Tag producer_tile.0() tag = "func_type_and_dim: 1 2 32 1 2 0 9 0 9" Tag consumer_tile.0() tag = "func_type_and_dim: 1 2 32 1 2 0 8 0 8" Store producer_tile.0(0, 0) = 0.000000 Store producer_tile.0(1, 0) = 0.000000 Store producer_tile.0(2, 0) = 0.000000 Store producer_tile.0(3, 0) = 0.000000 Store producer_tile.0(4, 0) = 0.000000 Store producer_tile.0(0, 1) = 0.000000 Store producer_tile.0(1, 1) = 0.841471 Store producer_tile.0(2, 1) = 0.909297 Store producer_tile.0(3, 1) = 0.141120 Store producer_tile.0(4, 1) = -0.756802 Store producer_tile.0(0, 2) = 0.000000 Store producer_tile.0(1, 2) = 0.909297 Store producer_tile.0(2, 2) = -0.756802 Store producer_tile.0(3, 2) = -0.279415 Store producer_tile.0(4, 2) = 0.989358 Store producer_tile.0(0, 3) = 0.000000 Store producer_tile.0(1, 3) = 0.141120 Store producer_tile.0(2, 3) = -0.279415 Store producer_tile.0(3, 3) = 0.412118 Store producer_tile.0(4, 3) = -0.536573 Store producer_tile.0(0, 4) = 0.000000 Store producer_tile.0(1, 4) = -0.756802 Store producer_tile.0(2, 4) = 0.989358 Store producer_tile.0(3, 4) = -0.536573 Store producer_tile.0(4, 4) = -0.287903 Store consumer_tile.0(0, 0) = 0.210368 Store consumer_tile.0(1, 0) = 0.437692 Store consumer_tile.0(2, 0) = 0.262604 Store consumer_tile.0(3, 0) = -0.153921 Store consumer_tile.0(0, 1) = 0.437692 Store consumer_tile.0(1, 1) = 0.475816 Store consumer_tile.0(2, 1) = 0.003550 Store consumer_tile.0(3, 1) = 0.023565 Store consumer_tile.0(0, 2) = 0.262604 Store consumer_tile.0(1, 2) = 0.003550 Store consumer_tile.0(2, 2) = -0.225879 Store consumer_tile.0(3, 2) = 0.146372 Store consumer_tile.0(0, 3) = -0.153921 Store consumer_tile.0(1, 3) = 0.023565 Store consumer_tile.0(2, 3) = 0.146372 Store consumer_tile.0(3, 3) = -0.237233 Store producer_tile.0(4, 0) = 0.000000 Store producer_tile.0(5, 0) = 0.000000 Store producer_tile.0(6, 0) = 0.000000 Store producer_tile.0(7, 0) = 0.000000 Store producer_tile.0(8, 0) = 0.000000 Store producer_tile.0(4, 1) = -0.756802 Store producer_tile.0(5, 1) = -0.958924 Store producer_tile.0(6, 1) = -0.279415 Store producer_tile.0(7, 1) = 0.656987 Store producer_tile.0(8, 1) = 0.989358 Store producer_tile.0(4, 2) = 0.989358 Store producer_tile.0(5, 2) = -0.544021 Store producer_tile.0(6, 2) = -0.536573 Store producer_tile.0(7, 2) = 0.990607 Store producer_tile.0(8, 2) = -0.287903 Store producer_tile.0(4, 3) = -0.536573 Store producer_tile.0(5, 3) = 0.650288 Store producer_tile.0(6, 3) = -0.750987 Store producer_tile.0(7, 3) = 0.836656 Store producer_tile.0(8, 3) = -0.905578 Store producer_tile.0(4, 4) = -0.287903 Store producer_tile.0(5, 4) = 0.912945 Store producer_tile.0(6, 4) = -0.905578 Store producer_tile.0(7, 4) = 0.270906 Store producer_tile.0(8, 4) = 0.551427 Store consumer_tile.0(4, 0) = -0.428932 Store consumer_tile.0(5, 0) = -0.309585 Store consumer_tile.0(6, 0) = 0.094393 Store consumer_tile.0(7, 0) = 0.411586 Store consumer_tile.0(4, 1) = -0.317597 Store consumer_tile.0(5, 1) = -0.579733 Store consumer_tile.0(6, 1) = 0.207901 Store consumer_tile.0(7, 1) = 0.587262 Store consumer_tile.0(4, 2) = 0.139763 Store consumer_tile.0(5, 2) = -0.295323 Store consumer_tile.0(6, 2) = 0.134926 Store consumer_tile.0(7, 2) = 0.158445 Store consumer_tile.0(4, 3) = 0.184689 Store consumer_tile.0(5, 3) = -0.023333 Store consumer_tile.0(6, 3) = -0.137251 Store consumer_tile.0(7, 3) = 0.188352 Store producer_tile.0(0, 4) = 0.000000 Store producer_tile.0(1, 4) = -0.756802 Store producer_tile.0(2, 4) = 0.989358 Store producer_tile.0(3, 4) = -0.536573 Store producer_tile.0(4, 4) = -0.287903 Store producer_tile.0(0, 5) = 0.000000 Store producer_tile.0(1, 5) = -0.958924 Store producer_tile.0(2, 5) = -0.544021 Store producer_tile.0(3, 5) = 0.650288 Store producer_tile.0(4, 5) = 0.912945 Store producer_tile.0(0, 6) = 0.000000 Store producer_tile.0(1, 6) = -0.279415 Store producer_tile.0(2, 6) = -0.536573 Store producer_tile.0(3, 6) = -0.750987 Store producer_tile.0(4, 6) = -0.905578 Store producer_tile.0(0, 7) = 0.000000 Store producer_tile.0(1, 7) = 0.656987 Store producer_tile.0(2, 7) = 0.990607 Store producer_tile.0(3, 7) = 0.836656 Store producer_tile.0(4, 7) = 0.270906 Store producer_tile.0(0, 8) = 0.000000 Store producer_tile.0(1, 8) = 0.989358 Store producer_tile.0(2, 8) = -0.287903 Store producer_tile.0(3, 8) = -0.905578 Store producer_tile.0(4, 8) = 0.551427 Store consumer_tile.0(0, 4) = -0.428932 Store consumer_tile.0(1, 4) = -0.317597 Store consumer_tile.0(2, 4) = 0.139763 Store consumer_tile.0(3, 4) = 0.184689 Store consumer_tile.0(0, 5) = -0.309585 Store consumer_tile.0(1, 5) = -0.579733 Store consumer_tile.0(2, 5) = -0.295323 Store consumer_tile.0(3, 5) = -0.023333 Store consumer_tile.0(0, 6) = 0.094393 Store consumer_tile.0(1, 6) = 0.207901 Store consumer_tile.0(2, 6) = 0.134926 Store consumer_tile.0(3, 6) = -0.137251 Store consumer_tile.0(0, 7) = 0.411586 Store consumer_tile.0(1, 7) = 0.587262 Store consumer_tile.0(2, 7) = 0.158445 Store consumer_tile.0(3, 7) = 0.188352 Store producer_tile.0(4, 4) = -0.287903 Store producer_tile.0(5, 4) = 0.912945 Store producer_tile.0(6, 4) = -0.905578 Store producer_tile.0(7, 4) = 0.270906 Store producer_tile.0(8, 4) = 0.551427 Store producer_tile.0(4, 5) = 0.912945 Store producer_tile.0(5, 5) = -0.132352 Store producer_tile.0(6, 5) = -0.988032 Store producer_tile.0(7, 5) = -0.428183 Store producer_tile.0(8, 5) = 0.745113 Store producer_tile.0(4, 6) = -0.905578 Store producer_tile.0(5, 6) = -0.988032 Store producer_tile.0(6, 6) = -0.991779 Store producer_tile.0(7, 6) = -0.916522 Store producer_tile.0(8, 6) = -0.768255 Store producer_tile.0(4, 7) = 0.270906 Store producer_tile.0(5, 7) = -0.428183 Store producer_tile.0(6, 7) = -0.916522 Store producer_tile.0(7, 7) = -0.953753 Store producer_tile.0(8, 7) = -0.521551 Store producer_tile.0(4, 8) = 0.551427 Store producer_tile.0(5, 8) = 0.745113 Store producer_tile.0(6, 8) = -0.768255 Store producer_tile.0(7, 8) = -0.521551 Store producer_tile.0(8, 8) = 0.920026 Store consumer_tile.0(4, 4) = 0.351409 Store consumer_tile.0(5, 4) = -0.278254 Store consumer_tile.0(6, 4) = -0.512722 Store consumer_tile.0(7, 4) = 0.284816 Store consumer_tile.0(4, 5) = -0.278254 Store consumer_tile.0(5, 5) = -0.775048 Store consumer_tile.0(6, 5) = -0.831129 Store consumer_tile.0(7, 5) = -0.341961 Store consumer_tile.0(4, 6) = -0.512722 Store consumer_tile.0(5, 6) = -0.831129 Store consumer_tile.0(6, 6) = -0.944644 Store consumer_tile.0(7, 6) = -0.790020 Store consumer_tile.0(4, 7) = 0.284816 Store consumer_tile.0(5, 7) = -0.341961 Store consumer_tile.0(6, 7) = -0.790020 Store consumer_tile.0(7, 7) = -0.269207 End pipeline consumer_tile.0()
// See below for a visualization.
// The producer and consumer now alternate on a per-tile
// basis. Here's the equivalent C:
float result[8][8];
// For every tile of the consumer:
for (int y_outer = 0; y_outer < 2; y_outer++) {
for (int x_outer = 0; x_outer < 2; x_outer++) {
// Compute the x and y coords of the start of this tile.
int x_base = x_outer * 4;
int y_base = y_outer * 4;
// Compute enough of producer to satisfy this tile. A
// 4x4 tile of the consumer requires a 5x5 tile of the
// producer.
float producer_storage[5][5];
for (int py = y_base; py < y_base + 5; py++) {
for (int px = x_base; px < x_base + 5; px++) {
producer_storage[py - y_base][px - x_base] = sin(px * py);
}
}
// Compute this tile of the consumer
for (int y_inner = 0; y_inner < 4; y_inner++) {
for (int x_inner = 0; x_inner < 4; x_inner++) {
int x = x_base + x_inner;
int y = y_base + y_inner;
result[y][x] =
(producer_storage[y - y_base][x - x_base] +
producer_storage[y - y_base + 1][x - x_base] +
producer_storage[y - y_base][x - x_base + 1] +
producer_storage[y - y_base + 1][x - x_base + 1]) / 4;
}
}
}
}
printf("Pseudo-code for the schedule:\n");
consumer.print_loop_nest();
Show output
produce consumer_tile:
for y.v1:
for x.v0:
produce producer_tile:
for y:
for x:
producer_tile(...) = ...
consume producer_tile:
for y.v3 in [0, 3]:
for x.v2 in [0, 3]:
consumer_tile(...) = ...
printf("\n");
// Tiling can make sense for problems like this one with
// stencils that reach outwards in x and y. Each tile can be
// computed independently in parallel, and the redundant work
// done by each tile isn't so bad once the tiles get large
// enough.
}
// Let's try a mixed strategy that combines what we have done with
// splitting, parallelizing, and vectorizing. This is one that
// often works well in practice for large images. If you
// understand this schedule, then you understand 95% of scheduling
// in Halide.
{
Func producer("producer_mixed"), consumer("consumer_mixed");
producer(x, y) = sin(x * y);
consumer(x, y) = (producer(x, y) +
producer(x, y + 1) +
producer(x + 1, y) +
producer(x + 1, y + 1)) / 4;
// Split the y coordinate of the consumer into strips of 16 scanlines:
Var yo, yi;
consumer.split(y, yo, yi, 16);
// Compute the strips using a thread pool and a task queue.
consumer.parallel(yo);
// Vectorize across x by a factor of four.
consumer.vectorize(x, 4);
// Now store the producer per-strip. This will be 17 scanlines
// of the producer (16+1), but hopefully it will fold down
// into a circular buffer of two scanlines:
producer.store_at(consumer, yo);
// Within each strip, compute the producer per scanline of the
// consumer, skipping work done on previous scanlines.
producer.compute_at(consumer, yi);
// Also vectorize the producer (because sin is vectorizable on x86 using SSE).
producer.vectorize(x, 4);
// Let's leave tracing off this time, because we're going to
// evaluate over a larger image.
// consumer.trace_stores();
// producer.trace_stores();
Buffer<float> halide_result = consumer.realize({160, 160});
// See below for a visualization.
// Here's the equivalent (serial) C:
float c_result[160][160];
// For every strip of 16 scanlines (this loop is parallel in
// the Halide version)
for (int yo = 0; yo < 160 / 16; yo++) {
int y_base = yo * 16;
// Allocate a two-scanline circular buffer for the producer
float producer_storage[2][161];
// For every scanline in the strip of 16:
for (int yi = 0; yi < 16; yi++) {
int y = y_base + yi;
for (int py = y; py < y + 2; py++) {
// Skip scanlines already computed *within this task*
if (yi > 0 && py == y) { continue; }
// Compute this scanline of the producer in 4-wide vectors
for (int x_vec = 0; x_vec < 160 / 4 + 1; x_vec++) {
int x_base = x_vec * 4;
// 4 doesn't divide 161, so push the last vector left
// (see lesson 05).
if (x_base > 161 - 4) { x_base = 161 - 4; }
// If you're on x86, Halide generates SSE code for this part:
int x[] = {x_base, x_base + 1, x_base + 2, x_base + 3};
float vec[4] = {sinf(x[0] * py), sinf(x[1] * py),
sinf(x[2] * py), sinf(x[3] * py)};
producer_storage[py & 1][x[0]] = vec[0];
producer_storage[py & 1][x[1]] = vec[1];
producer_storage[py & 1][x[2]] = vec[2];
producer_storage[py & 1][x[3]] = vec[3];
}
}
// Now compute consumer for this scanline:
for (int x_vec = 0; x_vec < 160 / 4; x_vec++) {
int x_base = x_vec * 4;
// Again, Halide's equivalent here uses SSE.
int x[] = {x_base, x_base + 1, x_base + 2, x_base + 3};
float vec[] = {
(producer_storage[y & 1][x[0]] +
producer_storage[(y + 1) & 1][x[0]] +
producer_storage[y & 1][x[0] + 1] +
producer_storage[(y + 1) & 1][x[0] + 1]) /
4,
(producer_storage[y & 1][x[1]] +
producer_storage[(y + 1) & 1][x[1]] +
producer_storage[y & 1][x[1] + 1] +
producer_storage[(y + 1) & 1][x[1] + 1]) /
4,
(producer_storage[y & 1][x[2]] +
producer_storage[(y + 1) & 1][x[2]] +
producer_storage[y & 1][x[2] + 1] +
producer_storage[(y + 1) & 1][x[2] + 1]) /
4,
(producer_storage[y & 1][x[3]] +
producer_storage[(y + 1) & 1][x[3]] +
producer_storage[y & 1][x[3] + 1] +
producer_storage[(y + 1) & 1][x[3] + 1]) /
4};
c_result[y][x[0]] = vec[0];
c_result[y][x[1]] = vec[1];
c_result[y][x[2]] = vec[2];
c_result[y][x[3]] = vec[3];
}
}
}
printf("Pseudo-code for the schedule:\n");
consumer.print_loop_nest();
Show output
produce consumer_mixed:
parallel y.v4:
store producer_mixed:
for y.v5. in [-1, 15]:
produce producer_mixed:
for x.x:
vectorized x.v7 in [0, 3]:
producer_mixed(...) = ...
consume producer_mixed:
for x.x:
vectorized x.v6 in [0, 3]:
consumer_mixed(...) = ...
printf("\n");
// Let's check the C result against the Halide result. Doing
// this I found several bugs in my C implementation, which
// should tell you something.
for (int y = 0; y < 160; y++) {
for (int x = 0; x < 160; x++) {
float error = halide_result(x, y) - c_result[y][x];
// It's floating-point math, so we'll allow some slop:
if (error < -0.001f || error > 0.001f) {
printf("halide_result(%d, %d) = %f instead of %f\n",
x, y, halide_result(x, y), c_result[y][x]);
return -1;
}
}
}
}
// This stuff is hard. We ended up in a three-way trade-off
// between memory bandwidth, redundant work, and
// parallelism. Halide can't make the correct choice for you
// automatically. Instead it tries to make it easier for
// you to explore various options, without messing up your
// program. In fact, Halide promises that scheduling calls like
// compute_root won't change the meaning of your algorithm -- you
// should get the same bits back no matter how you schedule
// things.
// So be empirical! Experiment with various schedules and keep a
// log of performance. Form hypotheses and then try to prove
// yourself wrong. Don't assume that you just need to vectorize
// your code by a factor of four and run it on eight cores and
// you'll get 32x faster. This almost never works. Modern systems
// are complex enough that you can't predict performance reliably
// without running your code.
// We suggest you start by scheduling all of your non-trivial
// stages compute_root, and then work from the end of the pipeline
// upwards, inlining, parallelizing, and vectorizing each stage in
// turn until you reach the top.
// Halide is not just about vectorizing and parallelizing your
// code. That's not enough to get you very far. Halide is about
// giving you tools that help you quickly explore different
// trade-offs between locality, redundant work, and parallelism,
// without messing up the actual result you're trying to compute.
printf("Success!\n");
return 0;
}
#!/usr/bin/python3
# Halide tutorial lesson 8
# This lesson demonstrates how schedule multi-stage pipelines.
import halide as hl
import numpy as np
import math
def main():
# First we'll declare some Vars to use below.
x, y = hl.Var("x"), hl.Var("y")
# Let's examine various scheduling options for a simple two stage
# pipeline. We'll start with the default schedule:
if True:
print("=" * 50)
producer, consumer = hl.Func("producer_default"), hl.Func("consumer_default")
# The first stage will be some simple pointwise math similar
# to our familiar gradient function. The value at position x,
# y is the sqrt of product of x and y.
producer[x, y] = hl.sqrt(x * y)
# Now we'll add a second stage which adds together multiple
# points in the first stage.
consumer[x, y] = (
producer[x, y]
+ producer[x, y + 1]
+ producer[x + 1, y]
+ producer[x + 1, y + 1]
)
# We'll turn on tracing for both functions.
consumer.trace_stores()
producer.trace_stores()
# And evaluate it over a 5x5 box.
print("\nEvaluating producer-consumer pipeline with default schedule")
consumer.realize([4, 4])
Show output
Begin pipeline consumer_default.0() Tag consumer_default.0() tag = "func_type_and_dim: 1 2 32 1 2 0 4 0 4" Store consumer_default.0(0, 0) = 1.000000 Store consumer_default.0(1, 0) = 2.414214 Store consumer_default.0(2, 0) = 3.146264 Store consumer_default.0(3, 0) = 3.732051 Store consumer_default.0(0, 1) = 2.414214 Store consumer_default.0(1, 1) = 5.828427 Store consumer_default.0(2, 1) = 7.595754 Store consumer_default.0(3, 1) = 9.009968 Store consumer_default.0(0, 2) = 3.146264 Store consumer_default.0(1, 2) = 7.595754 Store consumer_default.0(2, 2) = 9.898979 Store consumer_default.0(3, 2) = 11.742018 Store consumer_default.0(0, 3) = 3.732051 Store consumer_default.0(1, 3) = 9.009968 Store consumer_default.0(2, 3) = 11.742019 Store consumer_default.0(3, 3) = 13.928204 End pipeline consumer_default.0()
# There were no messages about computing values of the
# producer. This is because the default schedule fully
# inlines 'producer' into 'consumer'. It is as if we had
# written the following code instead:
# consumer[x, y] = (sqrt(x * y) +
# sqrt(x * (y + 1)) +
# sqrt((x + 1) * y) +
# sqrt((x + 1) * (y + 1)))
# All calls to 'producer' have been replaced with the body of
# 'producer', with the arguments substituted in for the
# variables.
# The equivalent Python code is:
result = np.empty((4, 4), dtype=np.float32)
for yy in range(4):
for xx in range(4):
result[yy][xx] = (
math.sqrt(xx * yy)
+ math.sqrt(xx * (yy + 1))
+ math.sqrt((xx + 1) * yy)
+ math.sqrt((xx + 1) * (yy + 1))
)
print()
# If we look at the loop nest, the producer doesn't appear
# at all. It has been inlined into the consumer.
print("Pseudo-code for the schedule:")
consumer.print_loop_nest()
Show output
produce consumer_default:
for y:
for x:
consumer_default(...) = ...
print()
# Next we'll examine the next simplest option - computing all
# values required in the producer before computing any of the
# consumer. We call this schedule "root".
if True:
print("=" * 50)
# Start with the same function definitions:
producer, consumer = hl.Func("producer_root"), hl.Func("consumer_root")
producer[x, y] = hl.sqrt(x * y)
consumer[x, y] = (
producer[x, y]
+ producer[x, y + 1]
+ producer[x + 1, y]
+ producer[x + 1, y + 1]
)
# Tell Halide to evaluate all of producer before any of consumer.
producer.compute_root()
# Turn on tracing.
consumer.trace_stores()
producer.trace_stores()
# Compile and run.
print("\nEvaluating producer.compute_root()")
consumer.realize([4, 4])
Show output
Begin pipeline consumer_root.0() Tag producer_root.0() tag = "func_type_and_dim: 1 2 32 1 2 0 5 0 5" Tag consumer_root.0() tag = "func_type_and_dim: 1 2 32 1 2 0 4 0 4" Store producer_root.0(0, 0) = 0.000000 Store producer_root.0(1, 0) = 0.000000 Store producer_root.0(2, 0) = 0.000000 Store producer_root.0(3, 0) = 0.000000 Store producer_root.0(4, 0) = 0.000000 Store producer_root.0(0, 1) = 0.000000 Store producer_root.0(1, 1) = 1.000000 Store producer_root.0(2, 1) = 1.414214 Store producer_root.0(3, 1) = 1.732051 Store producer_root.0(4, 1) = 2.000000 Store producer_root.0(0, 2) = 0.000000 Store producer_root.0(1, 2) = 1.414214 Store producer_root.0(2, 2) = 2.000000 Store producer_root.0(3, 2) = 2.449490 Store producer_root.0(4, 2) = 2.828427 Store producer_root.0(0, 3) = 0.000000 Store producer_root.0(1, 3) = 1.732051 Store producer_root.0(2, 3) = 2.449490 Store producer_root.0(3, 3) = 3.000000 Store producer_root.0(4, 3) = 3.464102 Store producer_root.0(0, 4) = 0.000000 Store producer_root.0(1, 4) = 2.000000 Store producer_root.0(2, 4) = 2.828427 Store producer_root.0(3, 4) = 3.464102 Store producer_root.0(4, 4) = 4.000000 Store consumer_root.0(0, 0) = 1.000000 Store consumer_root.0(1, 0) = 2.414214 Store consumer_root.0(2, 0) = 3.146264 Store consumer_root.0(3, 0) = 3.732051 Store consumer_root.0(0, 1) = 2.414214 Store consumer_root.0(1, 1) = 5.828427 Store consumer_root.0(2, 1) = 7.595754 Store consumer_root.0(3, 1) = 9.009968 Store consumer_root.0(0, 2) = 3.146264 Store consumer_root.0(1, 2) = 7.595754 Store consumer_root.0(2, 2) = 9.898979 Store consumer_root.0(3, 2) = 11.742019 Store consumer_root.0(0, 3) = 3.732051 Store consumer_root.0(1, 3) = 9.009968 Store consumer_root.0(2, 3) = 11.742019 Store consumer_root.0(3, 3) = 13.928204 End pipeline consumer_root.0()
# Reading the output we can see that:
# A) There were stores to producer.
# B) They all happened before any stores to consumer.
# Equivalent Python:
result = np.empty((4, 4), dtype=np.float32)
# Allocate some temporary storage for the producer.
producer_storage = np.empty((5, 5), dtype=np.float32)
# Compute the producer.
for yy in range(5):
for xx in range(5):
producer_storage[yy][xx] = math.sqrt(xx * yy)
# Compute the consumer. Skip the prints this time.
for yy in range(4):
for xx in range(4):
result[yy][xx] = (
producer_storage[yy][xx]
+ producer_storage[yy + 1][xx]
+ producer_storage[yy][xx + 1]
+ producer_storage[yy + 1][xx + 1]
)
# Note that consumer was evaluated over a 4x4 box, so Halide
# automatically inferred that producer was needed over a 5x5
# box. This is the same 'bounds inference' logic we saw in
# the previous lesson, where it was used to detect and avoid
# out-of-bounds reads from an input image.
# If we print the loop nest, we'll see something very
# similar to the Python above.
print("Pseudo-code for the schedule:")
consumer.print_loop_nest()
Show output
produce producer_root:
for y:
for x:
producer_root(...) = ...
consume producer_root:
produce consumer_root:
for y:
for x:
consumer_root(...) = ...
print()
# Let's compare the two approaches above from a performance
# perspective.
# Full inlining (the default schedule):
# - Temporary memory allocated: 0
# - Loads: 0
# - Stores: 16
# - Calls to sqrt: 64
# producer.compute_root():
# - Temporary memory allocated: 25 floats
# - Loads: 64
# - Stores: 39
# - Calls to sqrt: 25
# There's a trade-off here. Full inlining used minimal temporary
# memory and memory bandwidth, but did a whole bunch of redundant
# expensive math (calling sqrt). It evaluated most points in
# 'producer' four times. The second schedule,
# producer.compute_root(), did the minimum number of calls to
# sqrt, but used more temporary memory and more memory bandwidth.
# In any given situation the correct choice can be difficult to
# make. If you're memory-bandwidth limited, or don't have much
# memory (e.g. because you're running on an old cell-phone), then
# it can make sense to do redundant math. On the other hand, sqrt
# is expensive, so if you're compute-limited then fewer calls to
# sqrt will make your program faster. Adding vectorization or
# multi-core parallelism tilts the scales in favor of doing
# redundant work, because firing up multiple cpu cores increases
# the amount of math you can do per second, but doesn't increase
# your system memory bandwidth or capacity.
# We can make choices in between full inlining and
# compute_root. Next we'll alternate between computing the
# producer and consumer on a per-scanline basis:
if True:
print("=" * 50)
# Start with the same function definitions:
producer, consumer = hl.Func("producer_y"), hl.Func("consumer_y")
producer[x, y] = hl.sqrt(x * y)
consumer[x, y] = (
producer[x, y]
+ producer[x, y + 1]
+ producer[x + 1, y]
+ producer[x + 1, y + 1]
)
# Tell Halide to evaluate producer as needed per y coordinate
# of the consumer:
producer.compute_at(consumer, y)
# This places the code that computes the producer just
# *inside* the consumer's for loop over y, as in the
# equivalent Python below.
# Turn on tracing.
producer.trace_stores()
consumer.trace_stores()
# Compile and run.
print("\nEvaluating producer.compute_at(consumer, y)")
consumer.realize([4, 4])
Show output
Begin pipeline consumer_y.0() Tag producer_y.0() tag = "func_type_and_dim: 1 2 32 1 2 0 5 0 5" Tag consumer_y.0() tag = "func_type_and_dim: 1 2 32 1 2 0 4 0 4" Store producer_y.0(0, 0) = 0.000000 Store producer_y.0(1, 0) = 0.000000 Store producer_y.0(2, 0) = 0.000000 Store producer_y.0(3, 0) = 0.000000 Store producer_y.0(4, 0) = 0.000000 Store producer_y.0(0, 1) = 0.000000 Store producer_y.0(1, 1) = 1.000000 Store producer_y.0(2, 1) = 1.414214 Store producer_y.0(3, 1) = 1.732051 Store producer_y.0(4, 1) = 2.000000 Store consumer_y.0(0, 0) = 1.000000 Store consumer_y.0(1, 0) = 2.414214 Store consumer_y.0(2, 0) = 3.146264 Store consumer_y.0(3, 0) = 3.732051 Store producer_y.0(0, 1) = 0.000000 Store producer_y.0(1, 1) = 1.000000 Store producer_y.0(2, 1) = 1.414214 Store producer_y.0(3, 1) = 1.732051 Store producer_y.0(4, 1) = 2.000000 Store producer_y.0(0, 2) = 0.000000 Store producer_y.0(1, 2) = 1.414214 Store producer_y.0(2, 2) = 2.000000 Store producer_y.0(3, 2) = 2.449490 Store producer_y.0(4, 2) = 2.828427 Store consumer_y.0(0, 1) = 2.414214 Store consumer_y.0(1, 1) = 5.828427 Store consumer_y.0(2, 1) = 7.595754 Store consumer_y.0(3, 1) = 9.009968 Store producer_y.0(0, 2) = 0.000000 Store producer_y.0(1, 2) = 1.414214 Store producer_y.0(2, 2) = 2.000000 Store producer_y.0(3, 2) = 2.449490 Store producer_y.0(4, 2) = 2.828427 Store producer_y.0(0, 3) = 0.000000 Store producer_y.0(1, 3) = 1.732051 Store producer_y.0(2, 3) = 2.449490 Store producer_y.0(3, 3) = 3.000000 Store producer_y.0(4, 3) = 3.464102 Store consumer_y.0(0, 2) = 3.146264 Store consumer_y.0(1, 2) = 7.595754 Store consumer_y.0(2, 2) = 9.898979 Store consumer_y.0(3, 2) = 11.742019 Store producer_y.0(0, 3) = 0.000000 Store producer_y.0(1, 3) = 1.732051 Store producer_y.0(2, 3) = 2.449490 Store producer_y.0(3, 3) = 3.000000 Store producer_y.0(4, 3) = 3.464102 Store producer_y.0(0, 4) = 0.000000 Store producer_y.0(1, 4) = 2.000000 Store producer_y.0(2, 4) = 2.828427 Store producer_y.0(3, 4) = 3.464102 Store producer_y.0(4, 4) = 4.000000 Store consumer_y.0(0, 3) = 3.732051 Store consumer_y.0(1, 3) = 9.009968 Store consumer_y.0(2, 3) = 11.742019 Store consumer_y.0(3, 3) = 13.928204 End pipeline consumer_y.0()
# Reading the log you should see that producer and consumer
# alternate on a per-scanline basis. Let's look at the
# equivalent Python:
result = np.empty((4, 4), dtype=np.float32)
# There's an outer loop over scanlines of consumer:
for yy in range(4):
# Allocate space and compute enough of the producer to
# satisfy this single scanline of the consumer. This
# means a 5x2 box of the producer.
producer_storage = np.empty((2, 5), dtype=np.float32)
for py in range(yy, yy + 2):
for px in range(5):
producer_storage[py - yy][px] = math.sqrt(px * py)
# Compute a scanline of the consumer.
for xx in range(4):
result[yy][xx] = (
producer_storage[0][xx]
+ producer_storage[1][xx]
+ producer_storage[0][xx + 1]
+ producer_storage[1][xx + 1]
)
# Again, if we print the loop nest, we'll see something very
# similar to the Python above.
print("Pseudo-code for the schedule:")
consumer.print_loop_nest()
Show output
produce consumer_y:
for y:
produce producer_y:
for y:
for x:
producer_y(...) = ...
consume producer_y:
for x:
consumer_y(...) = ...
print()
# The performance characteristics of this strategy are in
# between inlining and compute root. We still allocate some
# temporary memory, but less that compute_root, and with
# better locality (we load from it soon after writing to it,
# so for larger images, values should still be in cache). We
# still do some redundant work, but less than full inlining:
# producer.compute_at(consumer, y):
# - Temporary memory allocated: 10 floats
# - Loads: 64
# - Stores: 56
# - Calls to sqrt: 40
# We could also say producer.compute_at(consumer, x), but this
# would be very similar to full inlining (the default
# schedule). Instead let's distinguish between the loop level at
# which we allocate storage for producer, and the loop level at
# which we actually compute it. This unlocks a few optimizations.
if True:
print("=" * 50)
producer = hl.Func("producer_store_root_compute_y")
consumer = hl.Func("consumer_store_root_compute_y")
producer[x, y] = hl.sqrt(x * y)
consumer[x, y] = (
producer[x, y]
+ producer[x, y + 1]
+ producer[x + 1, y]
+ producer[x + 1, y + 1]
)
# Tell Halide to make a buffer to store all of producer at
# the outermost level:
producer.store_root()
# ... but compute it as needed per y coordinate of the
# consumer.
producer.compute_at(consumer, y)
producer.trace_stores()
consumer.trace_stores()
print("\nEvaluating producer.store_root().compute_at(consumer, y)")
consumer.realize([4, 4])
Show output
Begin pipeline consumer_store_root_compute_y.0() Tag producer_store_root_compute_y.0() tag = "func_type_and_dim: 1 2 32 1 2 0 5 0 5" Tag consumer_store_root_compute_y.0() tag = "func_type_and_dim: 1 2 32 1 2 0 4 0 4" Store producer_store_root_compute_y.0(0, 0) = 0.000000 Store producer_store_root_compute_y.0(1, 0) = 0.000000 Store producer_store_root_compute_y.0(2, 0) = 0.000000 Store producer_store_root_compute_y.0(3, 0) = 0.000000 Store producer_store_root_compute_y.0(4, 0) = 0.000000 Store producer_store_root_compute_y.0(0, 1) = 0.000000 Store producer_store_root_compute_y.0(1, 1) = 1.000000 Store producer_store_root_compute_y.0(2, 1) = 1.414214 Store producer_store_root_compute_y.0(3, 1) = 1.732051 Store producer_store_root_compute_y.0(4, 1) = 2.000000 Store consumer_store_root_compute_y.0(0, 0) = 1.000000 Store consumer_store_root_compute_y.0(1, 0) = 2.414214 Store consumer_store_root_compute_y.0(2, 0) = 3.146264 Store consumer_store_root_compute_y.0(3, 0) = 3.732051 Store producer_store_root_compute_y.0(0, 2) = 0.000000 Store producer_store_root_compute_y.0(1, 2) = 1.414214 Store producer_store_root_compute_y.0(2, 2) = 2.000000 Store producer_store_root_compute_y.0(3, 2) = 2.449490 Store producer_store_root_compute_y.0(4, 2) = 2.828427 Store consumer_store_root_compute_y.0(0, 1) = 2.414214 Store consumer_store_root_compute_y.0(1, 1) = 5.828427 Store consumer_store_root_compute_y.0(2, 1) = 7.595754 Store consumer_store_root_compute_y.0(3, 1) = 9.009968 Store producer_store_root_compute_y.0(0, 3) = 0.000000 Store producer_store_root_compute_y.0(1, 3) = 1.732051 Store producer_store_root_compute_y.0(2, 3) = 2.449490 Store producer_store_root_compute_y.0(3, 3) = 3.000000 Store producer_store_root_compute_y.0(4, 3) = 3.464102 Store consumer_store_root_compute_y.0(0, 2) = 3.146264 Store consumer_store_root_compute_y.0(1, 2) = 7.595754 Store consumer_store_root_compute_y.0(2, 2) = 9.898979 Store consumer_store_root_compute_y.0(3, 2) = 11.742019 Store producer_store_root_compute_y.0(0, 4) = 0.000000 Store producer_store_root_compute_y.0(1, 4) = 2.000000 Store producer_store_root_compute_y.0(2, 4) = 2.828427 Store producer_store_root_compute_y.0(3, 4) = 3.464102 Store producer_store_root_compute_y.0(4, 4) = 4.000000 Store consumer_store_root_compute_y.0(0, 3) = 3.732051 Store consumer_store_root_compute_y.0(1, 3) = 9.009968 Store consumer_store_root_compute_y.0(2, 3) = 11.742019 Store consumer_store_root_compute_y.0(3, 3) = 13.928204 End pipeline consumer_store_root_compute_y.0()
# Reading the log you should see that producer and consumer
# again alternate on a per-scanline basis. It computes a 5x2
# box of the producer to satisfy the first scanline of the
# consumer, but after that it only computes a 5x1 box of the
# output for each new scanline of the consumer!
#
# Halide has detected that for all scanlines except for the
# first, it can reuse the values already sitting in the
# buffer we've allocated for producer. Let's look at the
# equivalent Python:
result = np.empty((4, 4), dtype=np.float32)
# producer.store_root() implies that storage goes here:
producer_storage = np.empty((5, 5), dtype=np.float32)
# There's an outer loop over scanlines of consumer:
for yy in range(4):
# Compute enough of the producer to satisfy this scanline
# of the consumer.
for py in range(yy, yy + 2):
# Skip over rows of producer that we've already
# computed in a previous iteration.
if yy > 0 and py == yy:
continue
for px in range(5):
producer_storage[py][px] = math.sqrt(px * py)
# Compute a scanline of the consumer.
for xx in range(4):
result[yy][xx] = (
producer_storage[yy][xx]
+ producer_storage[yy + 1][xx]
+ producer_storage[yy][xx + 1]
+ producer_storage[yy + 1][xx + 1]
)
print("Pseudo-code for the schedule:")
consumer.print_loop_nest()
Show output
store producer_store_root_compute_y:
produce consumer_store_root_compute_y:
for y.:
produce producer_store_root_compute_y:
for y:
for x:
producer_store_root_compute_y(...) = ...
consume producer_store_root_compute_y:
for x:
consumer_store_root_compute_y(...) = ...
print()
# The performance characteristics of this strategy are pretty
# good! The numbers are similar compute_root, except locality
# is better. We're doing the minimum number of sqrt calls,
# and we load values soon after they are stored, so we're
# probably making good use of the cache:
# producer.store_root().compute_at(consumer, y):
# - Temporary memory allocated: 10 floats
# - Loads: 64
# - Stores: 39
# - Calls to sqrt: 25
# Note that my claimed amount of memory allocated doesn't
# match the reference Python code. Halide is performing one more
# optimization under the hood. It folds the storage for the
# producer down into a circular buffer of two
# scanlines. Equivalent Python would actually look like this:
if True:
# Actually store 2 scanlines instead of 5
producer_storage = np.empty((2, 5), dtype=np.float32)
for yy in range(4):
for py in range(yy, yy + 2):
if yy > 0 and py == yy:
continue
for px in range(5):
# Stores to producer_storage have their y coordinate
# bit-masked.
producer_storage[py & 1][px] = math.sqrt(px * py)
# Compute a scanline of the consumer.
for xx in range(4):
# Loads from producer_storage have their y coordinate
# bit-masked.
result[yy][xx] = (
producer_storage[yy & 1][xx]
+ producer_storage[(yy + 1) & 1][xx]
+ producer_storage[yy & 1][xx + 1]
+ producer_storage[(yy + 1) & 1][xx + 1]
)
# We can do even better, by leaving the storage outermost, but
# moving the computation into the innermost loop:
if True:
print("=" * 50)
producer = hl.Func("producer_store_root_compute_x")
consumer = hl.Func("consumer_store_root_compute_x")
producer[x, y] = hl.sqrt(x * y)
consumer[x, y] = (
producer[x, y]
+ producer[x, y + 1]
+ producer[x + 1, y]
+ producer[x + 1, y + 1]
)
# Store outermost, compute innermost.
producer.store_root().compute_at(consumer, x)
producer.trace_stores()
consumer.trace_stores()
print("\nEvaluating producer.store_root().compute_at(consumer, x)")
consumer.realize([4, 4])
Show output
Begin pipeline consumer_store_root_compute_x.0() Tag producer_store_root_compute_x.0() tag = "func_type_and_dim: 1 2 32 1 2 0 5 0 5" Tag consumer_store_root_compute_x.0() tag = "func_type_and_dim: 1 2 32 1 2 0 4 0 4" Store producer_store_root_compute_x.0(0, 0) = 0.000000 Store producer_store_root_compute_x.0(1, 0) = 0.000000 Store producer_store_root_compute_x.0(2, 0) = 0.000000 Store producer_store_root_compute_x.0(3, 0) = 0.000000 Store producer_store_root_compute_x.0(4, 0) = 0.000000 Store producer_store_root_compute_x.0(0, 1) = 0.000000 Store producer_store_root_compute_x.0(1, 1) = 1.000000 Store consumer_store_root_compute_x.0(0, 0) = 1.000000 Store producer_store_root_compute_x.0(2, 1) = 1.414214 Store consumer_store_root_compute_x.0(1, 0) = 2.414214 Store producer_store_root_compute_x.0(3, 1) = 1.732051 Store consumer_store_root_compute_x.0(2, 0) = 3.146264 Store producer_store_root_compute_x.0(4, 1) = 2.000000 Store consumer_store_root_compute_x.0(3, 0) = 3.732051 Store producer_store_root_compute_x.0(0, 2) = 0.000000 Store producer_store_root_compute_x.0(1, 2) = 1.414214 Store consumer_store_root_compute_x.0(0, 1) = 2.414214 Store producer_store_root_compute_x.0(2, 2) = 2.000000 Store consumer_store_root_compute_x.0(1, 1) = 5.828427 Store producer_store_root_compute_x.0(3, 2) = 2.449490 Store consumer_store_root_compute_x.0(2, 1) = 7.595754 Store producer_store_root_compute_x.0(4, 2) = 2.828427 Store consumer_store_root_compute_x.0(3, 1) = 9.009968 Store producer_store_root_compute_x.0(0, 3) = 0.000000 Store producer_store_root_compute_x.0(1, 3) = 1.732051 Store consumer_store_root_compute_x.0(0, 2) = 3.146264 Store producer_store_root_compute_x.0(2, 3) = 2.449490 Store consumer_store_root_compute_x.0(1, 2) = 7.595754 Store producer_store_root_compute_x.0(3, 3) = 3.000000 Store consumer_store_root_compute_x.0(2, 2) = 9.898979 Store producer_store_root_compute_x.0(4, 3) = 3.464102 Store consumer_store_root_compute_x.0(3, 2) = 11.742019 Store producer_store_root_compute_x.0(0, 4) = 0.000000 Store producer_store_root_compute_x.0(1, 4) = 2.000000 Store consumer_store_root_compute_x.0(0, 3) = 3.732051 Store producer_store_root_compute_x.0(2, 4) = 2.828427 Store consumer_store_root_compute_x.0(1, 3) = 9.009968 Store producer_store_root_compute_x.0(3, 4) = 3.464102 Store consumer_store_root_compute_x.0(2, 3) = 11.742019 Store producer_store_root_compute_x.0(4, 4) = 4.000000 Store consumer_store_root_compute_x.0(3, 3) = 13.928204 End pipeline consumer_store_root_compute_x.0()
# Reading the log, you should see that producer and consumer
# now alternate on a per-pixel basis. Here's the equivalent Python:
result = np.empty((4, 4), dtype=np.float32)
# producer.store_root() implies that storage goes here, but
# we can fold it down into a circular buffer of two
# scanlines:
producer_storage = np.empty((2, 5), dtype=np.float32)
# For every pixel of the consumer:
for yy in range(4):
for xx in range(4):
# Compute enough of the producer to satisfyy this
# pixxel of the consumer, but skip values that we've
# alreadyy computed:
if (yy == 0) and (xx == 0):
producer_storage[yy & 1][xx] = math.sqrt(xx * yy)
if yy == 0:
producer_storage[yy & 1][xx + 1] = math.sqrt((xx + 1) * yy)
if xx == 0:
producer_storage[(yy + 1) & 1][xx] = math.sqrt(xx * (yy + 1))
producer_storage[(yy + 1) & 1][xx + 1] = math.sqrt((xx + 1) * (yy + 1))
result[yy][xx] = (
producer_storage[yy & 1][xx]
+ producer_storage[(yy + 1) & 1][xx]
+ producer_storage[yy & 1][xx + 1]
+ producer_storage[(yy + 1) & 1][xx + 1]
)
print("Pseudo-code for the schedule:")
consumer.print_loop_nest()
Show output
store producer_store_root_compute_x:
produce consumer_store_root_compute_x:
for y.:
for x.:
produce producer_store_root_compute_x:
for y:
for x:
producer_store_root_compute_x(...) = ...
consume producer_store_root_compute_x:
consumer_store_root_compute_x(...) = ...
print()
# The performance characteristics of this strategy are the
# best so far. One of the four values of the producer we need
# is probably still sitting in a register, so I won't count
# it as a load:
# producer.store_root().compute_at(consumer, x):
# - Temporary memory allocated: 10 floats
# - Loads: 48
# - Stores: 56
# - Calls to sqrt: 40
# So what's the catch? Why not always do
# producer.store_root().compute_at(consumer, x) for this type of
# code?
#
# The answer is parallelism. In both of the previous two
# strategies we've assumed that values computed on previous
# iterations are lying around for us to reuse. This assumes that
# previous values of x or y happened earlier in time and have
# finished. This is not true if you parallelize or vectorize
# either loop. If you parallelize, Halide won't inject the
# optimizations that skip work already done if there's a parallel
# loop in between the store_at level and the compute_at level,
# and won't fold the storage down into a circular buffer either,
# which makes our store_root pointless.
# We're running out of options. We can make new ones by
# splitting. We can store_at or compute_at at the natural
# variables of the consumer (x and y), or we can split x or y
# into new inner and outer sub-variables and then schedule with
# respect to those. We'll use this to express fusion in tiles:
if True:
print("=" * 50)
producer, consumer = hl.Func("producer_tile"), hl.Func("consumer_tile")
producer[x, y] = hl.sqrt(x * y)
consumer[x, y] = (
producer[x, y]
+ producer[x, y + 1]
+ producer[x + 1, y]
+ producer[x + 1, y + 1]
)
# Tile the consumer using 2x2 tiles.
x_outer, y_outer = hl.Var("x_outer"), hl.Var("y_outer")
x_inner, y_inner = hl.Var("x_inner"), hl.Var("y_inner")
consumer.tile(x, y, x_outer, y_outer, x_inner, y_inner, 2, 2)
# Compute the producer per tile of the consumer
producer.compute_at(consumer, x_outer)
# Notice that I wrote my schedule starting from the end of
# the pipeline (the consumer). This is because the schedule
# for the producer refers to x_outer, which we introduced
# when we tiled the consumer. You can write it in the other
# order, but it tends to be harder to read.
# Turn on tracing.
producer.trace_stores()
consumer.trace_stores()
print(
"\nEvaluating:"
"consumer.tile(x, y, x_outer, y_outer, x_inner, y_inner, 2, 2)"
"producer.compute_at(consumer, x_outer)"
)
consumer.realize([4, 4])
Show output
Begin pipeline consumer_tile.0() Tag producer_tile.0() tag = "func_type_and_dim: 1 2 32 1 2 0 5 0 5" Tag consumer_tile.0() tag = "func_type_and_dim: 1 2 32 1 2 0 4 0 4" Store producer_tile.0(0, 0) = 0.000000 Store producer_tile.0(1, 0) = 0.000000 Store producer_tile.0(2, 0) = 0.000000 Store producer_tile.0(0, 1) = 0.000000 Store producer_tile.0(1, 1) = 1.000000 Store producer_tile.0(2, 1) = 1.414214 Store producer_tile.0(0, 2) = 0.000000 Store producer_tile.0(1, 2) = 1.414214 Store producer_tile.0(2, 2) = 2.000000 Store consumer_tile.0(0, 0) = 1.000000 Store consumer_tile.0(1, 0) = 2.414214 Store consumer_tile.0(0, 1) = 2.414214 Store consumer_tile.0(1, 1) = 5.828427 Store producer_tile.0(2, 0) = 0.000000 Store producer_tile.0(3, 0) = 0.000000 Store producer_tile.0(4, 0) = 0.000000 Store producer_tile.0(2, 1) = 1.414214 Store producer_tile.0(3, 1) = 1.732051 Store producer_tile.0(4, 1) = 2.000000 Store producer_tile.0(2, 2) = 2.000000 Store producer_tile.0(3, 2) = 2.449490 Store producer_tile.0(4, 2) = 2.828427 Store consumer_tile.0(2, 0) = 3.146264 Store consumer_tile.0(3, 0) = 3.732051 Store consumer_tile.0(2, 1) = 7.595754 Store consumer_tile.0(3, 1) = 9.009968 Store producer_tile.0(0, 2) = 0.000000 Store producer_tile.0(1, 2) = 1.414214 Store producer_tile.0(2, 2) = 2.000000 Store producer_tile.0(0, 3) = 0.000000 Store producer_tile.0(1, 3) = 1.732051 Store producer_tile.0(2, 3) = 2.449490 Store producer_tile.0(0, 4) = 0.000000 Store producer_tile.0(1, 4) = 2.000000 Store producer_tile.0(2, 4) = 2.828427 Store consumer_tile.0(0, 2) = 3.146264 Store consumer_tile.0(1, 2) = 7.595754 Store consumer_tile.0(0, 3) = 3.732051 Store consumer_tile.0(1, 3) = 9.009968 Store producer_tile.0(2, 2) = 2.000000 Store producer_tile.0(3, 2) = 2.449490 Store producer_tile.0(4, 2) = 2.828427 Store producer_tile.0(2, 3) = 2.449490 Store producer_tile.0(3, 3) = 3.000000 Store producer_tile.0(4, 3) = 3.464102 Store producer_tile.0(2, 4) = 2.828427 Store producer_tile.0(3, 4) = 3.464102 Store producer_tile.0(4, 4) = 4.000000 Store consumer_tile.0(2, 2) = 9.898979 Store consumer_tile.0(3, 2) = 11.742019 Store consumer_tile.0(2, 3) = 11.742019 Store consumer_tile.0(3, 3) = 13.928204 End pipeline consumer_tile.0()
# Reading the log, you should see that producer and consumer
# now alternate on a per-tile basis. Here's the equivalent Python:
result = np.empty((4, 4), dtype=np.float32)
# For every tile of the consumer:
for y_outer in range(2):
for x_outer in range(2):
# Compute the x and y coords of the start of this tile.
x_base = x_outer * 2
y_base = y_outer * 2
# Compute enough of producer to satisfy this tile. A
# 2x2 tile of the consumer requires a 3x3 tile of the
# producer.
producer_storage = np.empty((3, 3), dtype=np.float32)
for py in range(y_base, y_base + 3):
for px in range(x_base + 3):
producer_storage[py - y_base][px - x_base] = math.sqrt(px * py)
# Compute this tile of the consumer
for y_inner in range(2):
for x_inner in range(2):
xx = x_base + x_inner
yy = y_base + y_inner
result[yy][xx] = (
producer_storage[yy - y_base][xx - x_base]
+ producer_storage[yy - y_base + 1][xx - x_base]
+ producer_storage[yy - y_base][xx - x_base + 1]
+ producer_storage[yy - y_base + 1][xx - x_base + 1]
)
print("Pseudo-code for the schedule:")
consumer.print_loop_nest()
Show output
produce consumer_tile:
for y.y_outer:
for x.x_outer:
produce producer_tile:
for y:
for x:
producer_tile(...) = ...
consume producer_tile:
for y.y_inner in [0, 1]:
for x.x_inner in [0, 1]:
consumer_tile(...) = ...
print()
# Tiling can make sense for problems like this one with
# stencils that reach outwards in x and y. Each tile can be
# computed independently in parallel, and the redundant work
# done by each tile isn't so bad once the tiles get large
# enough.
# Let's try a mixed strategy that combines what we have done with
# splitting, parallelizing, and vectorizing. This is one that
# often works well in practice for large images. If you
# understand this schedule, then you understand 95% of scheduling
# in Halide.
if True:
print("=" * 50)
producer, consumer = hl.Func("producer_mixed"), hl.Func("consumer_mixed")
producer[x, y] = hl.sqrt(x * y)
consumer[x, y] = (
producer[x, y]
+ producer[x, y + 1]
+ producer[x + 1, y]
+ producer[x + 1, y + 1]
)
# Split the y coordinate of the consumer into strips of 16 scanlines:
yo, yi = hl.Var("yo"), hl.Var("yi")
consumer.split(y, yo, yi, 16)
# Compute the strips using a thread pool and a task queue.
consumer.parallel(yo)
# Vectorize across x by a factor of four.
consumer.vectorize(x, 4)
# Now store the producer per-strip. This will be 17 scanlines
# of the producer (16+1), but hopefully it will fold down
# into a circular buffer of two scanlines:
producer.store_at(consumer, yo)
# Within each strip, compute the producer per scanline of the
# consumer, skipping work done on previous scanlines.
producer.compute_at(consumer, yi)
# Also vectorize the producer (because sqrt is vectorizable on x86
# using SSE).
producer.vectorize(x, 4)
# Let's leave tracing off this time, because we're going to
# evaluate over a larger image.
# consumer.trace_stores()
# producer.trace_stores()
halide_result = consumer.realize([800, 600])
# Here's the equivalent (serial) Python:
py_result = np.empty((600, 800), dtype=np.float32)
# For every strip of 16 scanlines
for yo in range(600 // 16 + 1): # (this loop is parallel in the Halide version)
# 16 doesn't divide 600, so push the last slice upwards to fit
# within [0, 599] (see lesson 05).
y_base = yo * 16
if y_base > (600 - 16):
y_base = 600 - 16
# Allocate a two-scanline circular buffer for the producer
producer_storage = np.empty((2, 801), dtype=np.float32)
# For every scanline in the strip of 16:
for yi in range(16):
yy = y_base + yi
for py in range(yy, yy + 2):
# Skip scanlines already computed *within this task*
if (yi > 0) and (py == yy):
continue
# Compute this scanline of the producer in 4-wide vectors
for x_vec in range(800 // 4 + 1):
x_base = x_vec * 4
# 4 doesn't divide 801, so push the last vector left
# (see lesson 05).
if x_base > (801 - 4):
x_base = 801 - 4
# If you're on x86, Halide generates SSE code for this
# part:
xx = [x_base + 0, x_base + 1, x_base + 2, x_base + 3]
vec = [
math.sqrt(xx[0] * py),
math.sqrt(xx[1] * py),
math.sqrt(xx[2] * py),
math.sqrt(xx[3] * py),
]
producer_storage[py & 1][xx[0]] = vec[0]
producer_storage[py & 1][xx[1]] = vec[1]
producer_storage[py & 1][xx[2]] = vec[2]
producer_storage[py & 1][xx[3]] = vec[3]
# Now compute consumer for this scanline:
for x_vec in range(800 // 4):
x_base = x_vec * 4
# Again, Halide's equivalent here uses SSE.
xx = [x_base, x_base + 1, x_base + 2, x_base + 3]
vec = [
(
producer_storage[yy & 1][xx[0]]
+ producer_storage[(yy + 1) & 1][xx[0]]
+ producer_storage[yy & 1][xx[0] + 1]
+ producer_storage[(yy + 1) & 1][xx[0] + 1]
),
(
producer_storage[yy & 1][xx[1]]
+ producer_storage[(yy + 1) & 1][xx[1]]
+ producer_storage[yy & 1][xx[1] + 1]
+ producer_storage[(yy + 1) & 1][xx[1] + 1]
),
(
producer_storage[yy & 1][xx[2]]
+ producer_storage[(yy + 1) & 1][xx[2]]
+ producer_storage[yy & 1][xx[2] + 1]
+ producer_storage[(yy + 1) & 1][xx[2] + 1]
),
(
producer_storage[yy & 1][xx[3]]
+ producer_storage[(yy + 1) & 1][xx[3]]
+ producer_storage[yy & 1][xx[3] + 1]
+ producer_storage[(yy + 1) & 1][xx[3] + 1]
),
]
py_result[yy][xx[0]] = vec[0]
py_result[yy][xx[1]] = vec[1]
py_result[yy][xx[2]] = vec[2]
py_result[yy][xx[3]] = vec[3]
print("Pseudo-code for the schedule:")
consumer.print_loop_nest()
Show output
produce consumer_mixed:
parallel y.yo:
store producer_mixed:
for y.yi. in [-1, 15]:
produce producer_mixed:
for x.x:
vectorized x.v1 in [0, 3]:
producer_mixed(...) = ...
consume producer_mixed:
for x.x:
vectorized x.v0 in [0, 3]:
consumer_mixed(...) = ...
print()
# Let's check the Python result against the Halide result.
# Doing this I found several bugs in my Python implementation,
# which should tell you something.
for yy in range(600):
for xx in range(800):
error = halide_result[xx, yy] - py_result[yy][xx]
# It's floating-point math, so we'll allow some slop:
assert abs(error) <= 0.001, (
f"halide_result({xx}, {yy}) = {halide_result[xx, yy]} instead of {py_result[yy][xx]}"
)
# This stuff is hard. We ended up in a three-way trade-off
# between memory bandwidth, redundant work, and
# parallelism. Halide can't make the correct choice for you
# automatically. Instead it tries to make it easier for
# you to explore various options, without messing up your
# program. In fact, Halide promises that scheduling calls like
# compute_root won't change the meaning of your algorithm -- you
# should get the same bits back no matter how you schedule
# things.
# So be empirical! Experiment with various schedules and keep a
# log of performance. Form hypotheses and then try to prove
# yourself wrong. Don't assume that you just need to vectorize
# your code by a factor of four and run it on eight cores and
# you'll get 32x faster. This almost never works. Modern systems
# are complex enough that you can't predict performance reliably
# without running your code.
# We suggest you start by scheduling all of your non-trivial
# stages compute_root, and then work from the end of the pipeline
# upwards, inlining, parallelizing, and vectorizing each stage in
# turn until you reach the top.
# Halide is not just about vectorizing and parallelizing your
# code. That's not enough to get you very far. Halide is about
# giving you tools that help you quickly explore different
# trade-offs between locality, redundant work, and parallelism,
# without messing up the actual result you're trying to compute.
print("=" * 50)
print("Success!")
return 0
if __name__ == "__main__":
main()