diff --git a/.lldbinit b/.lldbinit
new file mode 100644
index 000000000000..a07959c1b025
--- /dev/null
+++ b/.lldbinit
@@ -0,0 +1 @@
+command script import ./tools/lldbhalide.py
diff --git a/README.md b/README.md
index dfbf68324742..fd13c0a92f59 100644
--- a/README.md
+++ b/README.md
@@ -436,3 +436,4 @@ code to Halide:
 |------------------------------------------|---------------------------------------------------------------------------------------------------------------|
 | [CMake developer](doc/CodeStyleCMake.md) | Guidelines for authoring new CMake code.                                                                      |
 | [FuzzTesting](doc/FuzzTesting.md)        | Information about fuzz testing the Halide compiler (rather than pipelines). Intended for internal developers. |
+| [Testing](doc/Testing.md)                | Information about our test organization and debugging tips. Intended for internal developers.                 |
diff --git a/doc/Testing.md b/doc/Testing.md
new file mode 100644
index 000000000000..ac12e0c7b975
--- /dev/null
+++ b/doc/Testing.md
@@ -0,0 +1,125 @@
+# Testing
+
+Halide uses CTest as its primary test platform and runner.
+
+## Organization
+
+Halide's tests are organized beneath the top-level `test/` directory. These
+folders are described below:
+
+| Folder               | Description                                                                      |
+|----------------------|----------------------------------------------------------------------------------|
+| `autoschedulers/$AS` | Test for the `$AS` (e.g. `adams2019`) autoscheduler                              |
+| `common`             | Code that may be shared across multiple tests                                    |
+| `correctness`        | Tests that check correctness of various compiler properties                      |
+| `error`              | Tests that expect an exception to be thrown (or `abort()` to be called)          |
+| `failing_with_issue` | Correctness tests that are associated with a particular issue on GitHub          |
+| `fuzz`               | Fuzz tests. Read more at [FuzzTesting.md](FuzzTesting.md)                        |
+| `generator`          | Tests of Halide's AOT compilation infrastructure.                                |
+| `integration`        | Tests of Halide's CMake package for downstream use, including cross compilation. |
+| `performance`        | Tests that check that certain schedules indeed improve performance.              |
+| `runtime`            | Unit tests for the Halide runtime library                                        |
+| `warning`            | Tests that expected warnings are indeed issued.                                  |
+
+The tests in each of these directories are given CTest labels corresponding to
+the directory name. Thus, one can use `ctest -L generator` to run only the
+`generator` tests. The `performance` tests configure CTest to not run them
+concurrently with other tests (including each other).
+
+The vast majority of our tests are simple C++ executables that link to Halide,
+perform some checks, and print the special line `Success!` upon successful
+completion. There are three main exceptions to this:
+
+First, the `warning` tests are expected to print a line that reads
+`Warning:` and do not look for `Success!`.
+
+Second, some tests cannot run in all scenarios; for example, a test that
+measures CUDA performance requires a CUDA-capable GPU. In these cases, tests are
+expected to print `[SKIP]` and exit and not print `Success!` or `Warning:`.
+
+Finally, the `error` tests are expected to throw an (uncaught) exception that is
+not a `Halide::InternalError` (i.e. from a failing `internal_assert`). The logic
+for translating uncaught exceptions into successful tests is in
+`test/common/expect_abort.cpp`.
+
+## Debugging with LLDB
+
+We provide helpers for pretty-printing Halide's IR types in LLDB. The
+`.lldbinit` file at the repository root will load automatically if you launch
+`lldb` from this directory and your `~/.lldbinit` file contains the line,
+
+```
+settings set target.load-cwd-lldbinit true
+```
+
+If you prefer to avoid such global configuration, you can directly load the
+helpers with the LLDB command,
+
+```
+command script import ./tools/lldbhalide.py
+```
+
+again assuming that the repository root is your current working directory.
+
+To see the benefit of using these helpers, let us debug `correctness_bounds`:
+
+```
+$ lldb ./build/test/correctness/correctness_bounds
+(lldb) breakpoint set --file bounds.cpp --line 18
+Breakpoint 1: where = correctness_bounds`main + 864 at bounds.cpp:18:12, address = 0x0000000100002054
+(lldb) run
+Process 29325 launched: '/Users/areinking/dev/Halide/build/test/correctness/correctness_bounds' (arm64)
+Defining function...
+Process 29325 stopped
+* thread #1, queue = 'com.apple.main-thread', stop reason = breakpoint 1.1
+    frame #0: 0x0000000100002054 correctness_bounds`main(argc=1, argv=0x000000016fdff160) at bounds.cpp:18:12
+   15       g(x, y) = min(x, y);
+   16       h(x, y) = clamp(x + y, 20, 100);
+   17   
+-> 18       Var xo("xo"), yo("yo"), xi("xi"), yi("yi");
+   19   
+   20       Target target = get_jit_target_from_environment();
+   21       if (target.has_gpu_feature()) {
+Target 0: (correctness_bounds) stopped.
+(lldb) 
+```
+
+Now we can try to inspect the Func `h`. Without the helpers, we see:
+
+```
+(lldb) v h
+(Halide::Func) {
+  func = {
+    contents = {
+      strong = (ptr = 0x0000600002486a20)
+      weak = nullptr
+      idx = 0
+    }
+  }
+  pipeline_ = {
+    contents = (ptr = 0x0000000000000000)
+  }
+}
+```
+
+But if we load the helpers and try again, we get a much more useful output:
+
+```
+(lldb) command script import ./tools/lldbhalide.py
+(lldb) v h
+... lots of output ...
+```
+
+The amount of output here is maybe a bit _too_ much, but we gain the ability to
+more narrowly inspect data about the func:
+
+```
+(lldb) v h.func.init_def.values
+...
+(std::vector<Halide::Expr>) h.func.init_def.values = size=1 {
+  [0] = max(min(x + y, 100), 20)
+}
+```
+
+These helpers are particularly useful when using graphical debuggers, such as
+the one found in CLion.
diff --git a/src/IRPrinter.cpp b/src/IRPrinter.cpp
index a42431f232d0..73ef8652c144 100644
--- a/src/IRPrinter.cpp
+++ b/src/IRPrinter.cpp
@@ -567,6 +567,11 @@ void IRPrinter::print(const Stmt &ir) {
     ir.accept(this);
 }
 
+void IRPrinter::print_summary(const Stmt &ir) {
+    ScopedValue<bool> old(is_summary, true);
+    ir.accept(this);
+}
+
 void IRPrinter::print_list(const std::vector<Expr> &exprs) {
     for (size_t i = 0; i < exprs.size(); i++) {
         print_no_parens(exprs[i]);
@@ -865,7 +870,9 @@ void IRPrinter::visit(const Let *op) {
     stream << "let " << op->name << " = ";
     print(op->value);
     stream << " in ";
-    print(op->body);
+    if (!is_summary) {
+        print(op->body);
+    }
     close();
 }
 
@@ -875,7 +882,9 @@ void IRPrinter::visit(const LetStmt *op) {
     print_no_parens(op->value);
     stream << "\n";
 
-    print(op->body);
+    if (!is_summary) {
+        print(op->body);
+    }
 }
 
 void IRPrinter::visit(const AssertStmt *op) {
@@ -905,13 +914,9 @@ void IRPrinter::visit(const For *op) {
     print_no_parens(op->min);
     stream << ", ";
     print_no_parens(op->extent);
-    stream << ") {\n";
+    stream << ") ";
 
-    indent++;
-    print(op->body);
-    indent--;
-
-    stream << get_indent() << "}\n";
+    print_braced_stmt(op->body, 1);
 }
 
 void IRPrinter::visit(const Acquire *op) {
@@ -919,11 +924,8 @@ void IRPrinter::visit(const Acquire *op) {
     print_no_parens(op->semaphore);
     stream << ", ";
     print_no_parens(op->count);
-    stream << ") {\n";
-    indent++;
-    print(op->body);
-    indent--;
-    stream << get_indent() << "}\n";
+    stream << ") ";
+    print_braced_stmt(op->body, 1);
 }
 
 void IRPrinter::print_lets(const Let *let) {
@@ -932,7 +934,9 @@ void IRPrinter::print_lets(const Let *let) {
     stream << "let " << let->name << " = ";
     print_no_parens(let->value);
     stream << " in\n";
-    if (const Let *next = let->body.as<Let>()) {
+    if (is_summary) {
+        stream << get_indent() << "...\n";
+    } else if (const Let *next = let->body.as<Let>()) {
         print_lets(next);
     } else {
         stream << get_indent();
@@ -941,6 +945,19 @@ void IRPrinter::print_lets(const Let *let) {
     }
 }
 
+void IRPrinter::print_braced_stmt(const Stmt &stmt, int extra_indent) {
+    if (is_summary) {
+        stream << "{ ... }\n";
+        return;
+    }
+
+    stream << "{\n";
+    indent += extra_indent;
+    print(stmt);
+    indent -= extra_indent;
+    stream << get_indent() << "}\n";
+}
+
 void IRPrinter::visit(const Store *op) {
     stream << get_indent();
     const bool has_pred = !is_const_one(op->predicate);
@@ -1038,7 +1055,10 @@ void IRPrinter::visit(const Allocate *op) {
         stream << get_indent() << " custom_delete { " << op->free_function << "(" << op->name << "); }";
     }
     stream << "\n";
-    print(op->body);
+
+    if (!is_summary) {
+        print(op->body);
+    }
 }
 
 void IRPrinter::visit(const Free *op) {
@@ -1067,13 +1087,9 @@ void IRPrinter::visit(const Realize *op) {
         stream << " if ";
         print(op->condition);
     }
-    stream << " {\n";
-
-    indent++;
-    print(op->body);
-    indent--;
 
-    stream << get_indent() << "}\n";
+    stream << " ";
+    print_braced_stmt(op->body);
 }
 
 void IRPrinter::visit(const Prefetch *op) {
@@ -1102,12 +1118,16 @@ void IRPrinter::visit(const Prefetch *op) {
         indent--;
         stream << get_indent() << "}\n";
     }
-    print(op->body);
+    if (!is_summary) {
+        print(op->body);
+    }
 }
 
 void IRPrinter::visit(const Block *op) {
-    print(op->first);
-    print(op->rest);
+    if (!is_summary) {
+        print(op->first);
+        print(op->rest);
+    }
 }
 
 void IRPrinter::visit(const Fork *op) {
@@ -1121,14 +1141,23 @@ void IRPrinter::visit(const Fork *op) {
     stmts.push_back(rest);
 
     stream << get_indent() << "fork ";
-    for (const Stmt &s : stmts) {
-        stream << "{\n";
-        indent++;
-        print(s);
-        indent--;
-        stream << get_indent() << "} ";
+    if (is_summary) {
+        stream << "[" << stmts.size();
+        if (stmts.size() == 1) {
+            stream << " child]";
+        } else {
+            stream << " children]";
+        }
+    } else {
+        for (const Stmt &s : stmts) {
+            stream << "{\n";
+            indent++;
+            print(s);
+            indent--;
+            stream << get_indent() << "} ";
+        }
+        stream << "\n";
     }
-    stream << "\n";
 }
 
 void IRPrinter::visit(const IfThenElse *op) {
@@ -1209,32 +1238,43 @@ void IRPrinter::visit(const VectorReduce *op) {
 }
 
 void IRPrinter::visit(const Atomic *op) {
+    stream << get_indent();
+
     if (op->mutex_name.empty()) {
-        stream << get_indent() << "atomic ("
-               << op->producer_name << ") {\n";
+        stream << "atomic (" << op->producer_name << ") ";
     } else {
-        stream << get_indent() << "atomic ("
-               << op->producer_name << ", "
-               << op->mutex_name << ") {\n";
+        stream << "atomic (" << op->producer_name << ", " << op->mutex_name << ") ";
     }
-    indent += 2;
-    print(op->body);
-    indent -= 2;
-    stream << get_indent() << "}\n";
+
+    print_braced_stmt(op->body);
 }
 
 void IRPrinter::visit(const HoistedStorage *op) {
     if (op->name.empty()) {
-        stream << get_indent() << "hoisted_storage {\n";
+        stream << get_indent() << "hoisted_storage ";
     } else {
-        stream << get_indent() << "hoisted_storage (";
-        stream << op->name;
-        stream << ") {\n";
+        stream << get_indent() << "hoisted_storage (" << op->name << ") ";
     }
-    indent += 2;
-    print(op->body);
-    indent -= 2;
-    stream << get_indent() << "}\n";
+
+    print_braced_stmt(op->body);
+}
+
+std::string lldb_string(const Expr &ir) {
+    std::stringstream s{};
+    IRPrinter p(s);
+    p.print_no_parens(ir);
+    return s.str();
+}
+
+std::string lldb_string(const Internal::BaseExprNode *n) {
+    return lldb_string(Expr(n));
+}
+
+std::string lldb_string(const Stmt &ir) {
+    std::stringstream s{};
+    IRPrinter p(s);
+    p.print_summary(ir);
+    return s.str();
 }
 
 }  // namespace Internal
diff --git a/src/IRPrinter.h b/src/IRPrinter.h
index 48afef8603d3..b7b5084c1eff 100644
--- a/src/IRPrinter.h
+++ b/src/IRPrinter.h
@@ -134,6 +134,9 @@ class IRPrinter : public IRVisitor {
     /** emit a statement on the output stream */
     void print(const Stmt &);
 
+    /** emit a statement summary on the output stream */
+    void print_summary(const Stmt &);
+
     /** emit a comma delimited list of exprs, without any leading or
      * trailing punctuation. */
     void print_list(const std::vector<Expr> &exprs);
@@ -157,6 +160,10 @@ class IRPrinter : public IRVisitor {
      * surrounding set of parens. */
     bool implicit_parens = false;
 
+    /** Print only a summary of a statement, with sub-statements replaced by
+     * ellipses (...). */
+    bool is_summary = false;
+
     /** Either emits "(" or "", depending on the value of implicit_parens */
     void open();
 
@@ -170,6 +177,9 @@ class IRPrinter : public IRVisitor {
     /** A helper for printing a chain of lets with line breaks */
     void print_lets(const Let *let);
 
+    /** A helper for printing a braced statement */
+    void print_braced_stmt(const Stmt &, int extra_indent = 2);
+
     void visit(const IntImm *) override;
     void visit(const UIntImm *) override;
     void visit(const FloatImm *) override;
@@ -220,6 +230,13 @@ class IRPrinter : public IRVisitor {
     void visit(const HoistedStorage *) override;
 };
 
+/** Debugging helpers for LLDB */
+/// @{
+std::string lldb_string(const Expr &);
+std::string lldb_string(const Internal::BaseExprNode *);
+std::string lldb_string(const Stmt &);
+/// @}
+
 }  // namespace Internal
 }  // namespace Halide
 
diff --git a/tools/lldbhalide.py b/tools/lldbhalide.py
new file mode 100644
index 000000000000..75f199a8dcee
--- /dev/null
+++ b/tools/lldbhalide.py
@@ -0,0 +1,103 @@
+# Load this module into LLDB by running:
+#     command script import /path/to/Halide/tools/lldbhalide.py
+import functools
+
+import lldb
+
+
+def normalize(raw):
+    return raw.lstrip('"').rstrip('"').replace(r'\n', ' ').replace('  ', ' ')
+
+
+def summary_string(summary_fn):
+    @functools.wraps(summary_fn)
+    def wrapper(value, _):
+        if value is None or not value.IsValid():
+            return "<invalid>"
+
+        try:
+            return normalize(summary_fn(value).GetSummary())
+        except Exception as e:
+            return f"<error:{e},{value}>"
+
+    return wrapper
+
+
+@summary_string
+def call_name(value):
+    return value.EvaluateExpression("this->name()", lldb.SBExpressionOptions())
+
+
+@summary_string
+def call_lldb_string(value):
+    return value.EvaluateExpression(f"Halide::Internal::lldb_string(*this)", lldb.SBExpressionOptions())
+
+
+class ProxyChildrenProvider:
+    def __init__(self, valobj, _):
+        self.inner = valobj
+        self.update()
+
+    def update(self):
+        pass
+
+    def num_children(self):
+        return self.inner.GetNumChildren()
+
+    def get_child_index(self, name):
+        return self.inner.GetIndexOfChildWithName(name)
+
+    def get_child_at_index(self, index):
+        return self.inner.GetChildAtIndex(index)
+
+
+class IRChildrenProvider(ProxyChildrenProvider):
+    def __init__(self, valobj, _):
+        super().__init__(valobj.GetChildMemberWithName("ptr"), None)
+
+
+class BoxChildrenProvider(IRChildrenProvider):
+    def __init__(self, valobj, _):
+        super().__init__(valobj.GetChildMemberWithName("contents"), None)
+
+
+class FunctionChildrenProvider(ProxyChildrenProvider):
+    def __init__(self, valobj, _):
+        contents = valobj.EvaluateExpression("*this->contents.get()", lldb.SBExpressionOptions())
+        print(contents)
+        super().__init__(contents, None)
+
+
+def __lldb_init_module(debugger, _):
+    base_exprs = ["Add", "And", "Broadcast", "Call", "Cast", "Div", "EQ", "GE", "GT", "LE", "LT", "Let", "Load", "Max",
+                  "Min", "Mod", "Mul", "NE", "Not", "Or", "Ramp", "Reinterpret", "Select", "Shuffle", "Sub", "Variable",
+                  "VectorReduce"]
+
+    for ty in base_exprs:
+        debugger.HandleCommand(
+            f"type summary add Halide::Internal::{ty} --python-function lldbhalide.call_lldb_string"
+        )
+
+    for ty in ('Expr', 'Internal::Stmt'):
+        debugger.HandleCommand(
+            f"type summary add Halide::{ty} --python-function lldbhalide.call_lldb_string"
+        )
+        debugger.HandleCommand(
+            f'type synthetic add Halide::{ty} -l lldbhalide.IRChildrenProvider'
+        )
+
+    for ty in ("Definition", "FuncSchedule", "ReductionDomain", "StageSchedule"):
+        debugger.HandleCommand(
+            f"type synthetic add Halide::Internal::{ty} -l lldbhalide.BoxChildrenProvider"
+        )
+
+    debugger.HandleCommand(
+        f'type synthetic add Halide::Internal::Function -l lldbhalide.FunctionChildrenProvider'
+    )
+
+    debugger.HandleCommand("type summary add Halide::Internal::Dim -s '${var.var%S}'")
+    debugger.HandleCommand("type summary add Halide::RVar --python-function lldbhalide.call_name")
+    debugger.HandleCommand("type summary add Halide::Var --python-function lldbhalide.call_name")
+
+    debugger.HandleCommand("type summary add halide_type_t -s '${var.code%S} bits=${var.bits%u} lanes=${var.lanes%u}'")
+    debugger.HandleCommand("type summary add Halide::Internal::RefCount -s ${var.count.Value%S}")
