Merge changes Iaf5a8954,I8d879833

* changes:
  track_event_data_source: Make every TraceForCategoryBody NO_INLINE
  track_event_data_source: Preserve size of arguments
diff --git a/src/trace_processor/containers/row_map.h b/src/trace_processor/containers/row_map.h
index 45eedf5..a2a6a41 100644
--- a/src/trace_processor/containers/row_map.h
+++ b/src/trace_processor/containers/row_map.h
@@ -451,6 +451,12 @@
     NoVariantMatched();
   }
 
+  // Returns the data in RowMap BitVector, nullptr if RowMap is in a different
+  // mode.
+  const BitVector* GetIfBitVector() const {
+    return std::get_if<BitVector>(&data_);
+  }
+
   // Returns the iterator over the rows in this RowMap.
   Iterator IterateRows() const { return Iterator(this); }
 
diff --git a/src/trace_processor/db/overlays/null_overlay.cc b/src/trace_processor/db/overlays/null_overlay.cc
index ee27295..204f113 100644
--- a/src/trace_processor/db/overlays/null_overlay.cc
+++ b/src/trace_processor/db/overlays/null_overlay.cc
@@ -28,7 +28,7 @@
   uint32_t start = non_null_->CountSetBits(t_range.range.start);
   uint32_t end = non_null_->CountSetBits(t_range.range.end);
 
-  return StorageRange({Range(start, end)});
+  return StorageRange(start, end);
 }
 
 TableBitVector NullOverlay::MapToTableBitVector(StorageBitVector s_bv,
diff --git a/src/trace_processor/db/overlays/null_overlay_unittest.cc b/src/trace_processor/db/overlays/null_overlay_unittest.cc
index 0c02ad7..5782c3e 100644
--- a/src/trace_processor/db/overlays/null_overlay_unittest.cc
+++ b/src/trace_processor/db/overlays/null_overlay_unittest.cc
@@ -25,7 +25,7 @@
 TEST(NullOverlay, MapToStorageRangeOutsideBoundary) {
   BitVector bv{0, 0, 0, 1, 1, 0, 0, 1, 1, 0, 0};
   NullOverlay overlay(&bv);
-  StorageRange r = overlay.MapToStorageRange({RowMap::Range(1, 6)});
+  StorageRange r = overlay.MapToStorageRange(TableRange(1, 6));
 
   ASSERT_EQ(r.range.start, 0u);
   ASSERT_EQ(r.range.end, 2u);
@@ -34,7 +34,7 @@
 TEST(NullOverlay, MapToStorageRangeOnBoundary) {
   BitVector bv{0, 1, 0, 1, 1, 0, 0, 1, 1, 0, 0};
   NullOverlay overlay(&bv);
-  StorageRange r = overlay.MapToStorageRange({RowMap::Range(3, 8)});
+  StorageRange r = overlay.MapToStorageRange(TableRange(3, 8));
 
   ASSERT_EQ(r.range.start, 1u);
   ASSERT_EQ(r.range.end, 4u);
diff --git a/src/trace_processor/db/overlays/selector_overlay.h b/src/trace_processor/db/overlays/selector_overlay.h
index 1cad92c..73499b2 100644
--- a/src/trace_processor/db/overlays/selector_overlay.h
+++ b/src/trace_processor/db/overlays/selector_overlay.h
@@ -27,7 +27,7 @@
 // Overlay responsible for selecting specific rows from Storage.
 class SelectorOverlay : public StorageOverlay {
  public:
-  explicit SelectorOverlay(BitVector* selected) : selected_(selected) {}
+  explicit SelectorOverlay(const BitVector* selected) : selected_(selected) {}
 
   StorageRange MapToStorageRange(TableRange) const override;
 
@@ -44,7 +44,7 @@
   CostEstimatePerRow EstimateCostPerRow(OverlayOp) const override;
 
  private:
-  BitVector* selected_;
+  const BitVector* selected_;
 };
 
 }  // namespace overlays
diff --git a/src/trace_processor/db/overlays/selector_overlay_unittest.cc b/src/trace_processor/db/overlays/selector_overlay_unittest.cc
index 8c743e8..ccc6980 100644
--- a/src/trace_processor/db/overlays/selector_overlay_unittest.cc
+++ b/src/trace_processor/db/overlays/selector_overlay_unittest.cc
@@ -25,7 +25,7 @@
 TEST(SelectorOverlay, MapToStorageRangeFirst) {
   BitVector selector{0, 0, 0, 1, 1, 0, 1, 1, 0, 0, 1};
   SelectorOverlay overlay(&selector);
-  StorageRange r = overlay.MapToStorageRange({RowMap::Range(1, 4)});
+  StorageRange r = overlay.MapToStorageRange(TableRange(1, 4));
 
   ASSERT_EQ(r.range.start, 4u);
   ASSERT_EQ(r.range.end, 8u);
@@ -34,7 +34,7 @@
 TEST(SelectorOverlay, MapToStorageRangeSecond) {
   BitVector selector{0, 0, 0, 1, 1, 0, 1, 1, 0, 1, 0};
   SelectorOverlay overlay(&selector);
-  StorageRange r = overlay.MapToStorageRange({RowMap::Range(1, 3)});
+  StorageRange r = overlay.MapToStorageRange(TableRange(1, 3));
 
   ASSERT_EQ(r.range.start, 4u);
   ASSERT_EQ(r.range.end, 7u);
diff --git a/src/trace_processor/db/overlays/types.h b/src/trace_processor/db/overlays/types.h
index 7978ada..0ea50c5 100644
--- a/src/trace_processor/db/overlays/types.h
+++ b/src/trace_processor/db/overlays/types.h
@@ -26,11 +26,17 @@
 
 // A range of indices in the table space.
 struct TableRange {
+  TableRange(uint32_t start, uint32_t end) : range(start, end) {}
+  explicit TableRange(RowMap::Range r) : range(r) {}
+
   RowMap::Range range;
 };
 
 // A range of indices in the storage space.
 struct StorageRange {
+  StorageRange(uint32_t start, uint32_t end) : range(start, end) {}
+  explicit StorageRange(RowMap::Range r) : range(r) {}
+
   RowMap::Range range;
 };
 
diff --git a/src/trace_processor/db/query_executor.cc b/src/trace_processor/db/query_executor.cc
index 46cbd29..923e321 100644
--- a/src/trace_processor/db/query_executor.cc
+++ b/src/trace_processor/db/query_executor.cc
@@ -23,6 +23,7 @@
 #include "perfetto/base/logging.h"
 #include "perfetto/ext/base/status_or.h"
 #include "src/trace_processor/db/overlays/null_overlay.h"
+#include "src/trace_processor/db/overlays/selector_overlay.h"
 #include "src/trace_processor/db/overlays/storage_overlay.h"
 #include "src/trace_processor/db/query_executor.h"
 #include "src/trace_processor/db/storage/numeric_storage.h"
@@ -157,13 +158,13 @@
                                       const SimpleColumn& col,
                                       RowMap* rm) {
   // TODO(b/283763282): We should align these to word boundaries.
-  TableRange table_range{Range(rm->Get(0), rm->Get(rm->size() - 1) + 1)};
+  TableRange table_range(rm->Get(0), rm->Get(rm->size() - 1) + 1);
   base::SmallVector<Range, kMaxOverlayCount> overlay_bounds;
 
   for (const auto& overlay : col.overlays) {
     StorageRange storage_range = overlay->MapToStorageRange(table_range);
     overlay_bounds.emplace_back(storage_range.range);
-    table_range = TableRange({storage_range.range});
+    table_range = TableRange(storage_range.range);
   }
 
   // Use linear search algorithm on storage.
@@ -252,10 +253,10 @@
     use_legacy = use_legacy || (overlays::FilterOpToOverlayOp(c.op) ==
                                     overlays::OverlayOp::kOther &&
                                 col.type() != c.value.type);
-    use_legacy = use_legacy ||
-                 col.overlay().row_map().size() != col.storage_base().size();
     use_legacy = use_legacy || col.IsSorted() || col.IsDense() || col.IsSetId();
-    use_legacy = use_legacy || col.overlay().row_map().IsIndexVector();
+    use_legacy =
+        use_legacy || (col.overlay().size() != col.storage_base().size() &&
+                       !col.overlay().row_map().IsBitVector());
     if (use_legacy) {
       col.FilterInto(c.op, c.value, &rm);
       continue;
@@ -265,9 +266,14 @@
     uint32_t s_size = col.storage_base().non_null_size();
 
     storage::NumericStorage storage(s_data, s_size, col.col_type());
-    overlays::NullOverlay null_overlay(col.storage_base().bv());
-
     SimpleColumn s_col{OverlaysVec(), &storage};
+
+    overlays::SelectorOverlay selector_overlay(
+        col.overlay().row_map().GetIfBitVector());
+    if (col.overlay().size() != col.storage_base().size())
+      s_col.overlays.emplace_back(&selector_overlay);
+
+    overlays::NullOverlay null_overlay(col.storage_base().bv());
     if (col.IsNullable()) {
       s_col.overlays.emplace_back(&null_overlay);
     }
diff --git a/src/trace_processor/db/query_executor_benchmark.cc b/src/trace_processor/db/query_executor_benchmark.cc
index 55a7251..9024a50 100644
--- a/src/trace_processor/db/query_executor_benchmark.cc
+++ b/src/trace_processor/db/query_executor_benchmark.cc
@@ -20,6 +20,8 @@
 #include "perfetto/ext/base/string_utils.h"
 #include "src/base/test/utils.h"
 #include "src/trace_processor/db/query_executor.h"
+#include "src/trace_processor/db/table.h"
+#include "src/trace_processor/tables/metadata_tables_py.h"
 #include "src/trace_processor/tables/slice_tables_py.h"
 
 namespace perfetto {
@@ -27,6 +29,24 @@
 namespace {
 
 using SliceTable = tables::SliceTable;
+using ThreadTrackTable = tables::ThreadTrackTable;
+using ExpectedFrameTimelineSliceTable = tables::ExpectedFrameTimelineSliceTable;
+using RawTable = tables::RawTable;
+using FtraceEventTable = tables::FtraceEventTable;
+
+// `SELECT * FROM SLICE` on android_monitor_contention_trace.at
+static char kSliceTable[] = "test/data/slice_table_for_benchmarks.csv";
+
+// `SELECT * FROM SLICE` on android_monitor_contention_trace.at
+static char kExpectedFrameTimelineTable[] =
+    "test/data/expected_frame_timeline_for_benchmarks.csv";
+
+// `SELECT id, cpu FROM raw` on chrome_android_systrace.pftrace.
+static char kRawTable[] = "test/data/raw_cpu_for_benchmarks.csv";
+
+// `SELECT id, cpu FROM ftrace_event` on chrome_android_systrace.pftrace.
+static char kFtraceEventTable[] =
+    "test/data/ftrace_event_cpu_for_benchmarks.csv";
 
 enum DB { V1, V2 };
 
@@ -51,12 +71,10 @@
   return output;
 }
 
-std::vector<SliceTable::Row> LoadRowsFromCSVToSliceTable(
-    benchmark::State& state) {
-  std::vector<SliceTable::Row> rows;
+std::vector<std::string> ReadCSV(benchmark::State& state,
+                                 std::string file_name) {
   std::string table_csv;
-  static const char kTestTrace[] = "test/data/example_android_trace_30s.csv";
-  perfetto::base::ReadFile(perfetto::base::GetTestDataPath(kTestTrace),
+  perfetto::base::ReadFile(perfetto::base::GetTestDataPath(file_name),
                            &table_csv);
   if (table_csv.empty()) {
     state.SkipWithError(
@@ -65,44 +83,112 @@
     return {};
   }
   PERFETTO_CHECK(!table_csv.empty());
-
-  std::vector<std::string> rows_strings = base::SplitString(table_csv, "\n");
-  for (size_t i = 1; i < rows_strings.size(); ++i) {
-    std::vector<std::string> row_vec = SplitCSVLine(rows_strings[i]);
-    SliceTable::Row row;
-    PERFETTO_CHECK(row_vec.size() >= 12);
-    row.ts = *base::StringToInt64(row_vec[2]);
-    row.dur = *base::StringToInt64(row_vec[3]);
-    row.track_id =
-        tables::ThreadTrackTable::Id(*base::StringToUInt32(row_vec[4]));
-    row.depth = *base::StringToUInt32(row_vec[7]);
-    row.stack_id = *base::StringToInt32(row_vec[8]);
-    row.parent_stack_id = *base::StringToInt32(row_vec[9]);
-    row.parent_id = base::StringToUInt32(row_vec[11]).has_value()
-                        ? std::make_optional<SliceTable::Id>(
-                              *base::StringToUInt32(row_vec[11]))
-                        : std::nullopt;
-    row.arg_set_id = *base::StringToUInt32(row_vec[11]);
-    row.thread_ts = base::StringToInt64(row_vec[12]);
-    row.thread_dur = base::StringToInt64(row_vec[13]);
-    rows.emplace_back(row);
-  }
-  return rows;
+  return base::SplitString(table_csv, "\n");
 }
 
-struct BenchmarkSliceTable {
-  explicit BenchmarkSliceTable(benchmark::State& state) : table_{&pool_} {
-    auto rows = LoadRowsFromCSVToSliceTable(state);
-    for (uint32_t i = 0; i < rows.size(); ++i) {
-      table_.Insert(rows[i]);
+SliceTable::Row GetSliceTableRow(std::string string_row) {
+  std::vector<std::string> row_vec = SplitCSVLine(string_row);
+  SliceTable::Row row;
+  PERFETTO_CHECK(row_vec.size() >= 12);
+  row.ts = *base::StringToInt64(row_vec[2]);
+  row.dur = *base::StringToInt64(row_vec[3]);
+  row.track_id = ThreadTrackTable::Id(*base::StringToUInt32(row_vec[4]));
+  row.depth = *base::StringToUInt32(row_vec[7]);
+  row.stack_id = *base::StringToInt32(row_vec[8]);
+  row.parent_stack_id = *base::StringToInt32(row_vec[9]);
+  row.parent_id = base::StringToUInt32(row_vec[11]).has_value()
+                      ? std::make_optional<SliceTable::Id>(
+                            *base::StringToUInt32(row_vec[11]))
+                      : std::nullopt;
+  row.arg_set_id = *base::StringToUInt32(row_vec[11]);
+  row.thread_ts = base::StringToInt64(row_vec[12]);
+  row.thread_dur = base::StringToInt64(row_vec[13]);
+  return row;
+}
+
+struct SliceTableForBenchmark {
+  explicit SliceTableForBenchmark(benchmark::State& state) : table_{&pool_} {
+    std::vector<std::string> rows_strings = ReadCSV(state, kSliceTable);
+
+    for (size_t i = 1; i < rows_strings.size(); ++i) {
+      table_.Insert(GetSliceTableRow(rows_strings[i]));
     }
   }
+
   StringPool pool_;
   SliceTable table_;
 };
 
-void SliceTableBenchmark(benchmark::State& state,
-                         BenchmarkSliceTable& table,
+struct ExpectedFrameTimelineTableForBenchmark {
+  explicit ExpectedFrameTimelineTableForBenchmark(benchmark::State& state)
+      : table_{&pool_, &parent_} {
+    std::vector<std::string> table_rows_as_string =
+        ReadCSV(state, kExpectedFrameTimelineTable);
+    std::vector<std::string> parent_rows_as_string =
+        ReadCSV(state, kSliceTable);
+
+    uint32_t cur_idx = 0;
+    for (size_t i = 1; i < table_rows_as_string.size(); ++i, ++cur_idx) {
+      std::vector<std::string> row_vec = SplitCSVLine(table_rows_as_string[i]);
+
+      uint32_t idx = *base::StringToUInt32(row_vec[0]);
+      while (cur_idx < idx) {
+        parent_.Insert(GetSliceTableRow(parent_rows_as_string[cur_idx + 1]));
+        cur_idx++;
+      }
+
+      ExpectedFrameTimelineSliceTable::Row row;
+      row.ts = *base::StringToInt64(row_vec[2]);
+      row.dur = *base::StringToInt64(row_vec[3]);
+      row.track_id = ThreadTrackTable::Id(*base::StringToUInt32(row_vec[4]));
+      row.depth = *base::StringToUInt32(row_vec[7]);
+      row.stack_id = *base::StringToInt32(row_vec[8]);
+      row.parent_stack_id = *base::StringToInt32(row_vec[9]);
+      row.parent_id = base::StringToUInt32(row_vec[11]).has_value()
+                          ? std::make_optional<SliceTable::Id>(
+                                *base::StringToUInt32(row_vec[11]))
+                          : std::nullopt;
+      row.arg_set_id = *base::StringToUInt32(row_vec[11]);
+      row.thread_ts = base::StringToInt64(row_vec[12]);
+      row.thread_dur = base::StringToInt64(row_vec[13]);
+      table_.Insert(row);
+    }
+  }
+  StringPool pool_;
+  SliceTable parent_{&pool_};
+  ExpectedFrameTimelineSliceTable table_;
+};
+
+struct FtraceEventTableForBenchmark {
+  explicit FtraceEventTableForBenchmark(benchmark::State& state) {
+    std::vector<std::string> raw_rows = ReadCSV(state, kRawTable);
+    std::vector<std::string> ftrace_event_rows =
+        ReadCSV(state, kFtraceEventTable);
+
+    uint32_t cur_idx = 0;
+    for (size_t i = 1; i < ftrace_event_rows.size(); ++i, cur_idx++) {
+      std::vector<std::string> row_vec = SplitCSVLine(ftrace_event_rows[i]);
+      uint32_t idx = *base::StringToUInt32(row_vec[0]);
+      while (cur_idx < idx) {
+        std::vector<std::string> raw_row = SplitCSVLine(raw_rows[cur_idx + 1]);
+        RawTable::Row r;
+        r.cpu = *base::StringToUInt32(raw_row[1]);
+        raw_.Insert(r);
+        cur_idx++;
+      }
+      FtraceEventTable::Row row;
+      row.cpu = *base::StringToUInt32(row_vec[1]);
+      table_.Insert(row);
+    }
+  }
+
+  StringPool pool_;
+  RawTable raw_{&pool_};
+  tables::FtraceEventTable table_{&pool_, &raw_};
+};
+
+void BenchmarkSliceTable(benchmark::State& state,
+                         SliceTableForBenchmark& table,
                          Constraint c) {
   Table::kUseFilterV2 = state.range(0) == 1;
   for (auto _ : state) {
@@ -114,26 +200,66 @@
                              benchmark::Counter::kInvert);
 }
 
-static void BM_DBv2SliceTableTrackIdEquals(benchmark::State& state) {
-  BenchmarkSliceTable table(state);
-  SliceTableBenchmark(state, table, table.table_.track_id().eq(100));
+void BenchmarkExpectedFrameTable(benchmark::State& state,
+                                 ExpectedFrameTimelineTableForBenchmark& table,
+                                 Constraint c) {
+  Table::kUseFilterV2 = state.range(0) == 1;
+  for (auto _ : state) {
+    benchmark::DoNotOptimize(table.table_.FilterToRowMap({c}));
+  }
+  state.counters["s/row"] =
+      benchmark::Counter(static_cast<double>(table.table_.row_count()),
+                         benchmark::Counter::kIsIterationInvariantRate |
+                             benchmark::Counter::kInvert);
 }
 
-BENCHMARK(BM_DBv2SliceTableTrackIdEquals)->ArgsProduct({{DB::V1, DB::V2}});
-
-static void BM_DBv2SliceTableParentIdIsNotNull(benchmark::State& state) {
-  BenchmarkSliceTable table(state);
-  SliceTableBenchmark(state, table, table.table_.parent_id().is_not_null());
+void BenchmarkFtraceEventTable(benchmark::State& state,
+                               FtraceEventTableForBenchmark& table,
+                               Constraint c) {
+  Table::kUseFilterV2 = state.range(0) == 1;
+  for (auto _ : state) {
+    benchmark::DoNotOptimize(table.table_.FilterToRowMap({c}));
+  }
+  state.counters["s/row"] =
+      benchmark::Counter(static_cast<double>(table.table_.row_count()),
+                         benchmark::Counter::kIsIterationInvariantRate |
+                             benchmark::Counter::kInvert);
 }
 
-BENCHMARK(BM_DBv2SliceTableParentIdIsNotNull)->ArgsProduct({{DB::V1, DB::V2}});
-
-static void BM_DBv2SliceTableParentIdEq(benchmark::State& state) {
-  BenchmarkSliceTable table(state);
-  SliceTableBenchmark(state, table, table.table_.parent_id().eq(88));
+static void BM_QESliceTableTrackIdEq(benchmark::State& state) {
+  SliceTableForBenchmark table(state);
+  BenchmarkSliceTable(state, table, table.table_.track_id().eq(100));
 }
 
-BENCHMARK(BM_DBv2SliceTableParentIdEq)->ArgsProduct({{DB::V1, DB::V2}});
+BENCHMARK(BM_QESliceTableTrackIdEq)->ArgsProduct({{DB::V1, DB::V2}});
+
+static void BM_QESliceTableParentIdIsNotNull(benchmark::State& state) {
+  SliceTableForBenchmark table(state);
+  BenchmarkSliceTable(state, table, table.table_.parent_id().is_not_null());
+}
+
+BENCHMARK(BM_QESliceTableParentIdIsNotNull)->ArgsProduct({{DB::V1, DB::V2}});
+
+static void BM_QESliceTableParentIdEq(benchmark::State& state) {
+  SliceTableForBenchmark table(state);
+  BenchmarkSliceTable(state, table, table.table_.parent_id().eq(88));
+}
+
+BENCHMARK(BM_QESliceTableParentIdEq)->ArgsProduct({{DB::V1, DB::V2}});
+
+static void BM_QEFilterWithSparseSelector(benchmark::State& state) {
+  ExpectedFrameTimelineTableForBenchmark table(state);
+  BenchmarkExpectedFrameTable(state, table, table.table_.track_id().eq(88));
+}
+
+BENCHMARK(BM_QEFilterWithSparseSelector)->ArgsProduct({{DB::V1, DB::V2}});
+
+static void BM_QEFilterWithDenseSelector(benchmark::State& state) {
+  FtraceEventTableForBenchmark table(state);
+  BenchmarkFtraceEventTable(state, table, table.table_.cpu().eq(4));
+}
+
+BENCHMARK(BM_QEFilterWithDenseSelector)->ArgsProduct({{DB::V1, DB::V2}});
 
 }  // namespace
 }  // namespace trace_processor
diff --git a/src/trace_processor/db/query_executor_unittest.cc b/src/trace_processor/db/query_executor_unittest.cc
index 01fff7a..fa914c0 100644
--- a/src/trace_processor/db/query_executor_unittest.cc
+++ b/src/trace_processor/db/query_executor_unittest.cc
@@ -224,61 +224,63 @@
 }
 
 TEST(QueryExecutor, SingleConstraintWithNullAndSelector) {
-  std::vector<int64_t> storage_data{0, 1, 2, 3, 4, 0, 1, 2, 3, 4};
+  std::vector<int64_t> storage_data{0, 1, 2, 3, 0, 1, 2, 3};
   NumericStorage storage(storage_data.data(), 10, ColumnType::kInt64);
 
-  // Select 6 elements from storage, resulting in a vector {0, 1, 3, 4, 1, 2}.
-  BitVector selector_bv{1, 1, 0, 1, 1, 0, 1, 1, 0, 0};
-  SelectorOverlay selector_overlay(&selector_bv);
-
-  // Add nulls, final vector {0, 1, NULL, 3, 4, NULL, 1, 2, NULL}.
-  BitVector null_bv{1, 1, 0, 1, 1, 0, 1, 1, 0};
+  // Current vector
+  // 0, 1, NULL, 2, 3, 0, NULL, NULL, 1, 2, 3, NULL
+  BitVector null_bv{1, 1, 0, 1, 1, 1, 0, 0, 1, 1, 1, 0};
   NullOverlay null_overlay(&null_bv);
 
+  // Final vector
+  // 0, NULL, 3, NULL, 1, 3
+  BitVector selector_bv{1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0};
+  SelectorOverlay selector_overlay(&selector_bv);
+
   // Create the column.
   OverlaysVec overlays_vec;
-  overlays_vec.emplace_back(&null_overlay);
   overlays_vec.emplace_back(&selector_overlay);
+  overlays_vec.emplace_back(&null_overlay);
   SimpleColumn col{overlays_vec, &storage};
 
   // Filter.
   Constraint c{0, FilterOp::kGe, SqlValue::Long(2)};
-  QueryExecutor exec({col}, 9);
+  QueryExecutor exec({col}, 6);
   RowMap res = exec.Filter({c});
 
-  ASSERT_EQ(res.size(), 3u);
-  ASSERT_EQ(res.Get(0), 3u);
-  ASSERT_EQ(res.Get(1), 4u);
-  ASSERT_EQ(res.Get(2), 7u);
+  ASSERT_EQ(res.size(), 2u);
+  ASSERT_EQ(res.Get(0), 2u);
+  ASSERT_EQ(res.Get(1), 5u);
 }
 
 TEST(QueryExecutor, IsNull) {
-  std::vector<int64_t> storage_data{0, 1, 2, 3, 4, 0, 1, 2, 3, 4};
+  std::vector<int64_t> storage_data{0, 1, 2, 3, 0, 1, 2, 3};
   NumericStorage storage(storage_data.data(), 10, ColumnType::kInt64);
 
-  // Select 6 elements from storage, resulting in a vector {0, 1, 3, 4, 1, 2}.
-  BitVector selector_bv{1, 1, 0, 1, 1, 0, 1, 1, 0, 0};
-  SelectorOverlay selector_overlay(&selector_bv);
-
-  // Add nulls, final vector {0, 1, NULL, 3, 4, NULL, 1, 2, NULL}.
-  BitVector null_bv{1, 1, 0, 1, 1, 0, 1, 1, 0};
+  // Current vector
+  // 0, 1, NULL, 2, 3, 0, NULL, NULL, 1, 2, 3, NULL
+  BitVector null_bv{1, 1, 0, 1, 1, 1, 0, 0, 1, 1, 1, 0};
   NullOverlay null_overlay(&null_bv);
 
+  // Final vector
+  // 0, NULL, 3, NULL, 1, 3
+  BitVector selector_bv{1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0};
+  SelectorOverlay selector_overlay(&selector_bv);
+
   // Create the column.
   OverlaysVec overlays_vec;
-  overlays_vec.emplace_back(&null_overlay);
   overlays_vec.emplace_back(&selector_overlay);
+  overlays_vec.emplace_back(&null_overlay);
   SimpleColumn col{overlays_vec, &storage};
 
   // Filter.
   Constraint c{0, FilterOp::kIsNull, SqlValue::Long(0)};
-  QueryExecutor exec({col}, 9);
+  QueryExecutor exec({col}, 6);
   RowMap res = exec.Filter({c});
 
-  ASSERT_EQ(res.size(), 3u);
-  ASSERT_EQ(res.Get(0), 2u);
-  ASSERT_EQ(res.Get(1), 5u);
-  ASSERT_EQ(res.Get(2), 8u);
+  ASSERT_EQ(res.size(), 2u);
+  ASSERT_EQ(res.Get(0), 1u);
+  ASSERT_EQ(res.Get(1), 3u);
 }
 
 }  // namespace
diff --git a/tools/check_sql_modules.py b/tools/check_sql_modules.py
index 8037fca..f50fdbd 100755
--- a/tools/check_sql_modules.py
+++ b/tools/check_sql_modules.py
@@ -16,6 +16,7 @@
 # This tool checks that every SQL object created without prefix
 # 'internal_' is documented with proper schema.
 
+import argparse
 import os
 import sys
 
@@ -26,9 +27,13 @@
 
 
 def main():
+  parser = argparse.ArgumentParser()
+  parser.add_argument(
+      '--stdlib-sources',
+      default=os.path.join(ROOT_DIR, "src", "trace_processor", "stdlib"))
+  args = parser.parse_args()
   errors = []
-  metrics_sources = os.path.join(ROOT_DIR, "src", "trace_processor", "stdlib")
-  for root, _, files in os.walk(metrics_sources, topdown=True):
+  for root, _, files in os.walk(args.stdlib_sources, topdown=True):
     for f in files:
       path = os.path.join(root, f)
       if not path.endswith(".sql"):
diff --git a/ui/src/assets/common.scss b/ui/src/assets/common.scss
index 08c2ac6..94a9c0e 100644
--- a/ui/src/assets/common.scss
+++ b/ui/src/assets/common.scss
@@ -219,7 +219,8 @@
   }
 
   td {
-    padding: 2px 1px;
+    padding: 3px 5px;
+    white-space: nowrap;
 
     i.material-icons {
       // Shrink the icons inside the table cells to increase the information
diff --git a/ui/src/assets/perfetto.scss b/ui/src/assets/perfetto.scss
index 640b30e..95ed422 100644
--- a/ui/src/assets/perfetto.scss
+++ b/ui/src/assets/perfetto.scss
@@ -26,20 +26,21 @@
 @import "flags_page";
 @import "hiring_banner";
 @import "widgets_page";
+@import "widgets/anchor";
 @import "widgets/button";
 @import "widgets/checkbox";
-@import "widgets/text_input";
-@import "widgets/empty_state";
-@import "widgets/anchor";
-@import "widgets/popup";
-@import "widgets/multiselect";
-@import "widgets/select";
-@import "widgets/menu";
-@import "widgets/spinner";
-@import "widgets/tree";
-@import "widgets/switch";
-@import "widgets/form";
 @import "widgets/details_shell";
+@import "widgets/empty_state";
+@import "widgets/error";
+@import "widgets/form";
 @import "widgets/grid_layout";
+@import "widgets/menu";
+@import "widgets/multiselect";
+@import "widgets/popup";
 @import "widgets/section";
 @import "widgets/timestamp";
+@import "widgets/select";
+@import "widgets/spinner";
+@import "widgets/switch";
+@import "widgets/text_input";
+@import "widgets/tree";
diff --git a/ui/src/assets/widgets/error.scss b/ui/src/assets/widgets/error.scss
new file mode 100644
index 0000000..dfee07c
--- /dev/null
+++ b/ui/src/assets/widgets/error.scss
@@ -0,0 +1,20 @@
+// Copyright (C) 2023 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+.pf-error {
+  padding: 20px 10px;
+  color: hsl(-10, 50%, 50%);
+  font-family: $pf-font;
+  font-weight: 300;
+}
diff --git a/ui/src/base/array_utils.ts b/ui/src/base/array_utils.ts
index a3a9980..7236a43 100644
--- a/ui/src/base/array_utils.ts
+++ b/ui/src/base/array_utils.ts
@@ -31,3 +31,12 @@
 export function allUnique(x: string[]): boolean {
   return x.length == new Set(x).size;
 }
+
+export function arrayEquals(a: any[]|undefined, b: any[]|undefined): boolean {
+  if (a === undefined || b === undefined) return false;
+  if (a.length !== b.length) return false;
+  for (let i = 0; i < a.length; i++) {
+    if (a[i] !== b[i]) return false;
+  }
+  return true;
+}
diff --git a/ui/src/common/queries.ts b/ui/src/common/queries.ts
index 1d63819..bf2518a 100644
--- a/ui/src/common/queries.ts
+++ b/ui/src/common/queries.ts
@@ -28,8 +28,14 @@
   statementWithOutputCount: number;
 }
 
+export interface QueryRunParams {
+  // If true, replaces nulls with "NULL" string. Default is true.
+  convertNullsToString?: boolean;
+}
+
 export async function runQuery(
-    sqlQuery: string, engine: EngineProxy): Promise<QueryResponse> {
+    sqlQuery: string, engine: EngineProxy, params?: QueryRunParams):
+    Promise<QueryResponse> {
   const startMs = performance.now();
   const queryRes = engine.query(sqlQuery);
 
@@ -47,6 +53,8 @@
     // errored, the frontend will show a graceful message instead.
   }
 
+  const convertNullsToString = params?.convertNullsToString ?? true;
+
   const durationMs = performance.now() - startMs;
   const rows: Row[] = [];
   const columns = queryRes.columns();
@@ -55,7 +63,7 @@
     const row: Row = {};
     for (const colName of columns) {
       const value = iter.get(colName);
-      row[colName] = value === null ? 'NULL' : value;
+      row[colName] = value === null && convertNullsToString ? 'NULL' : value;
     }
     rows.push(row);
     if (++numRows >= MAX_DISPLAY_ROWS) break;
diff --git a/ui/src/common/query_result.ts b/ui/src/common/query_result.ts
index 90fb8f4..9a9883c 100644
--- a/ui/src/common/query_result.ts
+++ b/ui/src/common/query_result.ts
@@ -71,6 +71,7 @@
 export const LONG_NULL: bigint|null = 1n;
 
 export type ColumnType = string|number|bigint|null|Uint8Array;
+export type SqlValue = ColumnType;
 
 const SHIFT_32BITS = 32n;
 
@@ -159,7 +160,7 @@
 
 // One row extracted from an SQL result:
 export interface Row {
-  [key: string]: ColumnType|undefined;
+  [key: string]: ColumnType;
 }
 
 // The methods that any iterator has to implement.
diff --git a/ui/src/frontend/bottom_tab.ts b/ui/src/frontend/bottom_tab.ts
index f78bd0c..e192969 100644
--- a/ui/src/frontend/bottom_tab.ts
+++ b/ui/src/frontend/bottom_tab.ts
@@ -118,6 +118,10 @@
       void;
   abstract viewTab(): void|m.Children;
 
+  close(): void {
+    closeTab(this.uuid);
+  }
+
   createPanelVnode(): m.Vnode<any, any> {
     return m(
         BottomTabAdapter,
diff --git a/ui/src/frontend/chrome_slice_details_tab.ts b/ui/src/frontend/chrome_slice_details_tab.ts
index 4dd2fdf..54bfb5d 100644
--- a/ui/src/frontend/chrome_slice_details_tab.ts
+++ b/ui/src/frontend/chrome_slice_details_tab.ts
@@ -21,19 +21,27 @@
 import {runQuery} from '../common/queries';
 import {LONG, LONG_NULL, NUM, STR_NULL} from '../common/query_result';
 import {
+  formatDuration,
   TPDuration,
   TPTime,
 } from '../common/time';
 import {ArgNode, convertArgsToTree, Key} from '../controller/args_parser';
 
 import {Anchor} from './anchor';
-import {BottomTab, bottomTabRegistry, NewBottomTabArgs} from './bottom_tab';
+import {
+  addTab,
+  BottomTab,
+  bottomTabRegistry,
+  NewBottomTabArgs,
+} from './bottom_tab';
 import {FlowPoint, globals} from './globals';
 import {PanelSize} from './panel';
 import {runQueryInNewTab} from './query_result_tab';
 import {Icons} from './semantic_icons';
 import {Arg} from './sql/args';
 import {getSlice, SliceDetails, SliceRef} from './sql/slice';
+import {SqlTableTab} from './sql_table/tab';
+import {SqlTables} from './sql_table/well_known_tables';
 import {asSliceSqlId, asTPTimestamp} from './sql_types';
 import {getProcessName, getThreadName} from './thread_and_process_info';
 import {Button} from './widgets/button';
@@ -266,7 +274,7 @@
 function computeDuration(ts: TPTime, dur: TPDuration): m.Children {
   if (dur === -1n) {
     const minDuration = globals.state.traceTime.end - ts;
-    return [m(Duration, {dur: minDuration}), ' (Did not end)'];
+    return `${formatDuration(minDuration)} (Did not end)`;
   } else {
     return m(Duration, {dur});
   }
@@ -398,7 +406,28 @@
         {title: 'Details'},
         m(
             Tree,
-            m(TreeNode, {left: 'Name', right: slice.name}),
+            m(TreeNode, {
+              left: 'Name',
+              right: m(
+                  PopupMenu2,
+                  {
+                    trigger: m(Anchor, slice.name),
+                  },
+                  m(MenuItem, {
+                    label: 'Slices with the same name',
+                    onclick: () => {
+                      addTab({
+                        kind: SqlTableTab.kind,
+                        config: {
+                          table: SqlTables.slice,
+                          displayName: 'slice',
+                          filters: [`name = ${sqliteString(slice.name)}`],
+                        },
+                      });
+                    },
+                  }),
+                  ),
+            }),
             m(TreeNode, {
               left: 'Category',
               right: !slice.category || slice.category === '[NULL]' ?
diff --git a/ui/src/frontend/globals.ts b/ui/src/frontend/globals.ts
index be1f6cd..dfa4e0f 100644
--- a/ui/src/frontend/globals.ts
+++ b/ui/src/frontend/globals.ts
@@ -578,12 +578,16 @@
     this._ftracePanelData = data;
   }
 
-  makeSelection(action: DeferredAction<{}>, tabToOpen = 'current_selection') {
+  makeSelection(
+      action: DeferredAction<{}>, tab: string|null = 'current_selection') {
     const previousState = this.state;
     // A new selection should cancel the current search selection.
     globals.dispatch(Actions.setSearchIndex({index: -1}));
-    const tab = action.type === 'deselect' ? undefined : tabToOpen;
-    globals.dispatch(Actions.setCurrentTab({tab}));
+    if (action.type === 'deselect') {
+      globals.dispatch(Actions.setCurrentTab({tab: undefined}));
+    } else if (tab !== null) {
+      globals.dispatch(Actions.setCurrentTab({tab: tab}));
+    }
     globals.dispatch(action);
 
     // HACK(stevegolton + altimin): This is a workaround to allow passing the
diff --git a/ui/src/frontend/semantic_icons.ts b/ui/src/frontend/semantic_icons.ts
index a80acca..da6326e 100644
--- a/ui/src/frontend/semantic_icons.ts
+++ b/ui/src/frontend/semantic_icons.ts
@@ -19,4 +19,12 @@
   static readonly ContextMenu = 'arrow_drop_down';  // Could be 'more_vert'
   static readonly Copy = 'content_copy';
   static readonly Delete = 'delete';
+  static readonly SortedAsc = 'arrow_upward';
+  static readonly SortedDesc = 'arrow_downward';
+  static readonly GoBack = 'chevron_left';
+  static readonly GoForward = 'chevron_right';
+  static readonly AddColumn = 'add';
+  static readonly Close = 'close';
+  static readonly Hide = 'visibility_off';
+  static readonly Filter = 'filter_list';
 }
diff --git a/ui/src/frontend/sql/slice.ts b/ui/src/frontend/sql/slice.ts
index d29706b..fa9c5d6 100644
--- a/ui/src/frontend/sql/slice.ts
+++ b/ui/src/frontend/sql/slice.ts
@@ -185,10 +185,16 @@
   readonly ts: TPTimestamp;
   readonly dur: TPDuration;
   readonly sqlTrackId: number;
+
+  // Whether clicking on the reference should change the current tab
+  // to "current selection" tab in addition to updating the selection
+  // and changing the viewport. True by default.
+  readonly switchToCurrentSelectionTab?: boolean;
 }
 
 export class SliceRef implements m.ClassComponent<SliceRefAttrs> {
   view(vnode: m.Vnode<SliceRefAttrs>) {
+    const switchTab = vnode.attrs.switchToCurrentSelectionTab ?? true;
     return m(
         Anchor,
         {
@@ -201,8 +207,10 @@
             // Clamp duration to 1 - i.e. for instant events
             const dur = BigintMath.max(1n, vnode.attrs.dur);
             focusHorizontalRange(vnode.attrs.ts, vnode.attrs.ts + dur);
-            globals.makeSelection(Actions.selectChromeSlice(
-                {id: vnode.attrs.id, trackId: uiTrackId, table: 'slice'}));
+            globals.makeSelection(
+                Actions.selectChromeSlice(
+                    {id: vnode.attrs.id, trackId: uiTrackId, table: 'slice'}),
+                switchTab ? 'current_selection' : null);
           },
         },
         vnode.attrs.name);
diff --git a/ui/src/frontend/sql_table/argument_selector.ts b/ui/src/frontend/sql_table/argument_selector.ts
new file mode 100644
index 0000000..0189244
--- /dev/null
+++ b/ui/src/frontend/sql_table/argument_selector.ts
@@ -0,0 +1,85 @@
+// Copyright (C) 2023 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import m from 'mithril';
+
+import {EngineProxy} from '../../common/engine';
+import {STR} from '../../common/query_result';
+import {globals} from '../globals';
+import {constraintsToQueryFragment} from '../sql_utils';
+import {FilterableSelect} from '../widgets/select';
+import {Spinner} from '../widgets/spinner';
+
+import {argColumn} from './column';
+import {ArgSetIdColumn} from './table_description';
+
+const MAX_ARGS_TO_DISPLAY = 15;
+
+interface ArgumentSelectorAttrs {
+  engine: EngineProxy;
+  argSetId: ArgSetIdColumn;
+  tableName: string;
+  filters: string[];
+  // List of aliases for existing columns by the table.
+  alreadySelectedColumns: Set<string>;
+  onArgumentSelected: (argument: string) => void;
+}
+
+// A widget which allows the user to select a new argument to display.
+// Dinamically queries Trace Processor to find the relevant set of arg_set_ids
+// and which args are present in these arg sets.
+export class ArgumentSelector implements
+    m.ClassComponent<ArgumentSelectorAttrs> {
+  argList?: string[];
+
+  constructor({attrs}: m.Vnode<ArgumentSelectorAttrs>) {
+    this.load(attrs);
+  }
+
+  private async load(attrs: ArgumentSelectorAttrs) {
+    const queryResult = await attrs.engine.query(`
+      -- Encapsulate the query in a CTE to avoid clashes between filters
+      -- and columns of the 'args' table.
+      WITH arg_sets AS (
+        SELECT DISTINCT ${attrs.argSetId.name} as arg_set_id
+        FROM ${attrs.tableName}
+        ${constraintsToQueryFragment({
+      filters: attrs.filters,
+    })}
+      )
+      SELECT
+        DISTINCT args.key as key
+      FROM arg_sets
+      JOIN args USING (arg_set_id)
+    `);
+    this.argList = [];
+    const it = queryResult.iter({key: STR});
+    for (; it.valid(); it.next()) {
+      const arg = argColumn(attrs.argSetId, it.key);
+      if (attrs.alreadySelectedColumns.has(arg.alias)) continue;
+      this.argList.push(it.key);
+    }
+    globals.rafScheduler.scheduleFullRedraw();
+  }
+
+  view({attrs}: m.Vnode<ArgumentSelectorAttrs>) {
+    if (this.argList === undefined) return m(Spinner);
+    return m(FilterableSelect, {
+      values: this.argList,
+      onSelected: (value: string) => attrs.onArgumentSelected(value),
+      maxDisplayedItems: MAX_ARGS_TO_DISPLAY,
+      autofocusInput: true,
+    });
+  }
+}
diff --git a/ui/src/frontend/sql_table/column.ts b/ui/src/frontend/sql_table/column.ts
new file mode 100644
index 0000000..a57a85b
--- /dev/null
+++ b/ui/src/frontend/sql_table/column.ts
@@ -0,0 +1,66 @@
+// Copyright (C) 2023 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import {sqliteString} from '../../base/string_utils';
+
+import {
+  ArgSetIdColumn,
+  dependendentColumns,
+  DisplayConfig,
+  RegularSqlTableColumn,
+} from './table_description';
+
+// This file contains the defintions of different column types that can be
+// displayed in the table viewer.
+
+export interface Column {
+  // SQL expression calculating the value of this column.
+  expression: string;
+  // Unique name for this column.
+  // The relevant bit of SQL fetching this column will be ${expression} as
+  // ${alias}.
+  alias: string;
+  // Title to be displayed in the table header.
+  title: string;
+  // How the value of this column should be rendered.
+  display?: DisplayConfig;
+}
+
+export function columnFromSqlTableColumn(c: RegularSqlTableColumn): Column {
+  return {
+    expression: c.name,
+    alias: c.name,
+    title: c.title || c.name,
+    display: c.display,
+  };
+}
+
+export function argColumn(c: ArgSetIdColumn, argName: string): Column {
+  const escape = (name: string) => name.replace(/\.|\[|\]/g, '_');
+  return {
+    expression: `extract_arg(${c.name}, ${sqliteString(argName)}`,
+    alias: `_arg_${c.name}_${escape(argName)}`,
+    title: `${c.title ?? c.name} ${argName}`,
+  };
+}
+
+// Returns a list of projections (i.e. parts of the SELECT clause) that should
+// be added to the query fetching the data to be able to display the given
+// column (e.g. `foo` or `f(bar) as baz`).
+// Some table columns are backed by multiple SQL columns (e.g. slice_id is
+// backed by id, ts, dur and track_id), so we need to return a list.
+export function sqlProjectionsForColumn(column: Column): string[] {
+  return [`${column.expression} as ${column.alias}`].concat(
+      dependendentColumns(column.display).map((c) => `${c} as ${c}`));
+}
diff --git a/ui/src/frontend/sql_table/render_cell.ts b/ui/src/frontend/sql_table/render_cell.ts
new file mode 100644
index 0000000..4fdc99b
--- /dev/null
+++ b/ui/src/frontend/sql_table/render_cell.ts
@@ -0,0 +1,206 @@
+// Copyright (C) 2023 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import m from 'mithril';
+
+import {sqliteString} from '../../base/string_utils';
+import {Row, SqlValue} from '../../common/query_result';
+import {formatDuration, TPTime} from '../../common/time';
+import {Anchor} from '../anchor';
+import {copyToClipboard} from '../clipboard';
+import {Icons} from '../semantic_icons';
+import {SliceRef} from '../sql/slice';
+import {asSliceSqlId, asTPTimestamp} from '../sql_types';
+import {sqlValueToString} from '../sql_utils';
+import {Err} from '../widgets/error';
+import {MenuItem, PopupMenu2} from '../widgets/menu';
+import {renderTimecode} from '../widgets/timestamp';
+
+import {Column} from './column';
+import {SqlTableState} from './state';
+import {SliceIdDisplayConfig} from './table_description';
+
+// This file is responsible for rendering a value in a given sell based on the
+// column type.
+
+function filterOptionMenuItem(
+    label: string, filter: string, state: SqlTableState): m.Child {
+  return m(MenuItem, {
+    label,
+    onclick: () => {
+      state.addFilter(filter);
+    },
+  });
+}
+
+function getStandardFilters(
+    c: Column, value: SqlValue, state: SqlTableState): m.Child[] {
+  if (value === null) {
+    return [
+      filterOptionMenuItem('is null', `${c.expression} is null`, state),
+      filterOptionMenuItem('is not null', `${c.expression} is not null`, state),
+    ];
+  }
+  if (typeof value === 'string') {
+    return [
+      filterOptionMenuItem(
+          'equals to', `${c.expression} = ${sqliteString(value)}`, state),
+      filterOptionMenuItem(
+          'not equals to', `${c.expression} != ${sqliteString(value)}`, state),
+    ];
+  }
+  if (typeof value === 'bigint' || typeof value === 'number') {
+    return [
+      filterOptionMenuItem('equals to', `${c.expression} = ${value}`, state),
+      filterOptionMenuItem(
+          'not equals to', `${c.expression} != ${value}`, state),
+      filterOptionMenuItem('greater than', `${c.expression} > ${value}`, state),
+      filterOptionMenuItem(
+          'greater or equals than', `${c.expression} >= ${value}`, state),
+      filterOptionMenuItem('less than', `${c.expression} < ${value}`, state),
+      filterOptionMenuItem(
+          'less or equals than', `${c.expression} <= ${value}`, state),
+    ];
+  }
+  return [];
+}
+
+function displayValue(value: SqlValue): m.Child {
+  if (value === null) {
+    return m('i', 'NULL');
+  }
+  return sqlValueToString(value);
+}
+
+function displayTimestamp(value: SqlValue): m.Children {
+  if (typeof value !== 'bigint') return displayValue(value);
+  return renderTimecode(asTPTimestamp(value));
+}
+
+function displayDuration(value: TPTime): string;
+function displayDuration(value: SqlValue): m.Children;
+function displayDuration(value: SqlValue): m.Children {
+  if (typeof value !== 'bigint') return displayValue(value);
+  return formatDuration(value);
+}
+
+function display(column: Column, row: Row): m.Children {
+  const value = row[column.alias];
+
+  // Handle all cases when we have non-trivial formatting.
+  switch (column.display?.type) {
+    case 'timestamp':
+      return displayTimestamp(value);
+    case 'duration':
+    case 'thread_duration':
+      return displayDuration(value);
+  }
+
+  return displayValue(value);
+}
+
+function copyMenuItem(label: string, value: string): m.Child {
+  return m(MenuItem, {
+    icon: Icons.Copy,
+    label,
+    onclick: () => {
+      copyToClipboard(value);
+    },
+  });
+}
+
+function getContextMenuItems(
+    column: Column, row: Row, state: SqlTableState): m.Child[] {
+  const result: m.Child[] = [];
+  const value = row[column.alias];
+
+  if (column.display?.type === 'timestamp' && typeof value === 'bigint') {
+    result.push(copyMenuItem('Copy raw timestamp', `${value}`));
+    // result.push(
+    //    copyMenuItem('Copy formatted timestamp', displayTimestamp(value)));
+  }
+  if ((column.display?.type === 'duration' ||
+       column.display?.type === 'thread_duration') &&
+      typeof value === 'bigint') {
+    result.push(copyMenuItem('Copy raw duration', `${value}`));
+    result.push(
+        copyMenuItem('Copy formatted duration', displayDuration(value)));
+  }
+  if (typeof value === 'string') {
+    result.push(copyMenuItem('Copy', value));
+  }
+
+  const filters = getStandardFilters(column, value, state);
+  if (filters.length > 0) {
+    result.push(
+        m(MenuItem, {label: 'Add filter', icon: Icons.Filter}, ...filters));
+  }
+
+  return result;
+}
+
+function renderSliceIdColumn(
+    column: {alias: string, display: SliceIdDisplayConfig},
+    row: Row): m.Children {
+  const config = column.display;
+  const id = row[column.alias];
+  const ts = row[config.ts];
+  const dur = row[config.dur] === null ? -1n : row[config.dur];
+  const trackId = row[config.trackId];
+
+  const columnNotFoundError = (type: string, name: string) =>
+      m(Err, `${type} column ${name} not found`);
+  const wrongTypeError = (type: string, name: string, value: SqlValue) =>
+      m(Err,
+        `Wrong type for ${type} column ${name}: bigint expected, ${
+            typeof value} found`);
+
+  if (typeof id !== 'bigint') return sqlValueToString(id);
+  if (ts === undefined) return columnNotFoundError('Timestamp', config.ts);
+  if (typeof ts !== 'bigint') return wrongTypeError('timestamp', config.ts, ts);
+  if (dur === undefined) return columnNotFoundError('Duration', config.dur);
+  if (typeof dur !== 'bigint') {
+    return wrongTypeError('duration', config.dur, ts);
+  }
+  if (trackId === undefined) return columnNotFoundError('Track id', trackId);
+  if (typeof trackId !== 'bigint') {
+    return wrongTypeError('track id', config.trackId, trackId);
+  }
+
+  return m(SliceRef, {
+    id: asSliceSqlId(Number(id)),
+    name: `${id}`,
+    ts: asTPTimestamp(ts as bigint),
+    dur: dur,
+    sqlTrackId: Number(trackId),
+    switchToCurrentSelectionTab: false,
+  });
+}
+
+export function renderCell(
+    column: Column, row: Row, state: SqlTableState): m.Children {
+  if (column.display && column.display.type === 'slice_id') {
+    return renderSliceIdColumn(
+        {alias: column.alias, display: column.display}, row);
+  }
+  const displayValue = display(column, row);
+  const contextMenuItems: m.Child[] = getContextMenuItems(column, row, state);
+  return m(
+      PopupMenu2,
+      {
+        trigger: m(Anchor, displayValue),
+      },
+      ...contextMenuItems,
+  );
+}
diff --git a/ui/src/frontend/sql_table/state.ts b/ui/src/frontend/sql_table/state.ts
new file mode 100644
index 0000000..81fd2e7
--- /dev/null
+++ b/ui/src/frontend/sql_table/state.ts
@@ -0,0 +1,295 @@
+// Copyright (C) 2023 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import {arrayEquals} from '../../base/array_utils';
+import {SortDirection} from '../../base/comparison_utils';
+import {EngineProxy} from '../../common/engine';
+import {NUM, Row} from '../../common/query_result';
+import {globals} from '../globals';
+import {constraintsToQueryFragment} from '../sql_utils';
+import {
+  Column,
+  columnFromSqlTableColumn,
+  sqlProjectionsForColumn,
+} from './column';
+import {SqlTableDescription} from './table_description';
+
+interface ColumnOrderClause {
+  // We only allow the table to be sorted by the columns which are displayed to
+  // the user to avoid confusion, so we use a reference to the underlying Column
+  // here and compare it by reference down the line.
+  column: Column;
+  direction: SortDirection;
+}
+
+const ROW_LIMIT = 100;
+
+// Result of the execution of the query.
+interface Data {
+  // Rows to show, including pagination.
+  rows: Row[];
+  error?: string;
+}
+
+interface RowCount {
+  // Total number of rows in view, excluding the pagination.
+  // Undefined if the query returned an error.
+  count: number;
+  // Filters which were used to compute this row count.
+  // We need to recompute the totalRowCount only when filters change and not
+  // when the set of columns / order by changes.
+  filters: string[];
+}
+
+export class SqlTableState {
+  private readonly engine_: EngineProxy;
+  private readonly table_: SqlTableDescription;
+
+  get engine() {
+    return this.engine_;
+  }
+  get table() {
+    return this.table_;
+  }
+
+  private filters: string[];
+  private columns: Column[];
+  private orderBy: ColumnOrderClause[];
+  private offset = 0;
+  private data?: Data;
+  private rowCount?: RowCount;
+
+  constructor(
+      engine: EngineProxy, table: SqlTableDescription, filters?: string[]) {
+    this.engine_ = engine;
+    this.table_ = table;
+
+    this.filters = filters || [];
+    this.columns = [];
+    for (const column of this.table.columns) {
+      if (column.startsHidden) continue;
+      this.columns.push(columnFromSqlTableColumn(column));
+    }
+    this.orderBy = [];
+
+    this.reload();
+  }
+
+  // Compute the actual columns to fetch.
+  private getSQLProjections(): string[] {
+    const result = new Set<string>();
+    for (const column of this.columns) {
+      for (const p of sqlProjectionsForColumn(column)) {
+        result.add(p);
+      }
+    }
+    return Array.from(result);
+  }
+
+  private getSQLImports() {
+    return (this.table.imports || [])
+        .map((i) => `SELECT IMPORT("${i}");`)
+        .join('\n');
+  }
+
+  private getCountRowsSQLQuery(): string {
+    return `
+      ${this.getSQLImports()}
+
+      SELECT
+        COUNT() AS count
+      FROM ${this.table.name}
+      ${constraintsToQueryFragment({
+      filters: this.filters,
+    })}
+    `;
+  }
+
+  getNonPaginatedSQLQuery(): string {
+    const orderBy = this.orderBy.map((c) => ({
+                                       fieldName: c.column.alias,
+                                       direction: c.direction,
+                                     }));
+    return `
+      ${this.getSQLImports()}
+
+      SELECT
+        ${this.getSQLProjections().join(',\n')}
+      FROM ${this.table.name}
+      ${constraintsToQueryFragment({
+      filters: this.filters,
+      orderBy: orderBy,
+    })}
+    `;
+  }
+
+  getPaginatedSQLQuery():
+      string {  // We fetch one more row to determine if we can go forward.
+    return `
+      ${this.getNonPaginatedSQLQuery()}
+      LIMIT ${ROW_LIMIT + 1}
+      OFFSET ${this.offset}
+    `;
+  }
+
+  canGoForward(): boolean {
+    if (this.data === undefined) return false;
+    return this.data.rows.length > ROW_LIMIT;
+  }
+
+  canGoBack(): boolean {
+    if (this.data === undefined) return false;
+    return this.offset > 0;
+  }
+
+  goForward() {
+    if (!this.canGoForward()) return;
+    this.offset += ROW_LIMIT;
+    this.reload({offset: 'keep'});
+  }
+
+  goBack() {
+    if (!this.canGoBack()) return;
+    this.offset -= ROW_LIMIT;
+    this.reload({offset: 'keep'});
+  }
+
+  getDisplayedRange(): {from: number, to: number}|undefined {
+    if (this.data === undefined) return undefined;
+    return {
+      from: this.offset + 1,
+      to: this.offset + Math.min(this.data.rows.length, ROW_LIMIT),
+    };
+  }
+
+  private async loadRowCount(): Promise<RowCount|undefined> {
+    const filters = Array.from(this.filters);
+    const res = await this.engine.query(this.getCountRowsSQLQuery());
+    if (res.error() !== undefined) return undefined;
+    return {
+      count: res.firstRow({count: NUM}).count,
+      filters: filters,
+    };
+  }
+
+  private async loadData(): Promise<Data> {
+    const queryRes = await this.engine.query(this.getPaginatedSQLQuery());
+    const rows: Row[] = [];
+    for (const it = queryRes.iter({}); it.valid(); it.next()) {
+      const row: Row = {};
+      for (const column of queryRes.columns()) {
+        row[column] = it.get(column);
+      }
+      rows.push(row);
+    }
+
+    return {
+      rows,
+      error: queryRes.error(),
+    };
+  }
+
+  private async reload(params?: {offset: 'reset'|'keep'}) {
+    if ((params?.offset ?? 'reset') === 'reset') {
+      this.offset = 0;
+    }
+    const updateRowCount = !arrayEquals(this.rowCount?.filters, this.filters);
+    this.data = undefined;
+    if (updateRowCount) {
+      this.rowCount = undefined;
+    }
+
+    // Delay the visual update by 50ms to avoid flickering (if the query returns
+    // before the data is loaded.
+    setTimeout(() => globals.rafScheduler.scheduleFullRedraw(), 50);
+
+    if (updateRowCount) {
+      this.rowCount = await this.loadRowCount();
+    }
+    this.data = await this.loadData();
+
+    globals.rafScheduler.scheduleFullRedraw();
+  }
+
+  getTotalRowCount(): number|undefined {
+    return this.rowCount?.count;
+  }
+
+  getDisplayedRows(): Row[] {
+    return this.data?.rows || [];
+  }
+
+  getQueryError(): string|undefined {
+    return this.data?.error;
+  }
+
+  isLoading() {
+    return this.data === undefined;
+  }
+
+  removeFilter(filter: string) {
+    this.filters.splice(this.filters.indexOf(filter), 1);
+    this.reload();
+  }
+
+  addFilter(filter: string) {
+    this.filters.push(filter);
+    this.reload();
+  }
+
+  getFilters(): string[] {
+    return this.filters;
+  }
+
+  sortBy(clause: ColumnOrderClause) {
+    this.orderBy = this.orderBy || [];
+    // Remove previous sort by the same column.
+    this.orderBy = this.orderBy.filter((c) => c.column !== clause.column);
+    // Add the new sort clause to the front, so we effectively stable-sort the
+    // data currently displayed to the user.
+    this.orderBy.unshift(clause);
+    this.reload();
+  }
+
+  unsort() {
+    this.orderBy = [];
+    this.reload();
+  }
+
+  isSortedBy(column: Column): SortDirection|undefined {
+    if (!this.orderBy) return undefined;
+    if (this.orderBy.length === 0) return undefined;
+    if (this.orderBy[0].column !== column) return undefined;
+    return this.orderBy[0].direction;
+  }
+
+  addColumn(column: Column, index: number) {
+    this.columns.splice(index + 1, 0, column);
+    this.reload({offset: 'keep'});
+  }
+
+  hideColumnAtIndex(index: number) {
+    const column = this.columns[index];
+    this.columns.splice(index, 1);
+    // We can only filter by the visibile columns to avoid confusing the user,
+    // so we remove order by clauses that refer to the hidden column.
+    this.orderBy = this.orderBy.filter((c) => c.column !== column);
+    // TODO(altimin): we can avoid the fetch here if the orderBy hasn't changed.
+    this.reload({offset: 'keep'});
+  }
+
+  getSelectedColumns(): Column[] {
+    return this.columns;
+  }
+};
diff --git a/ui/src/frontend/sql_table/tab.ts b/ui/src/frontend/sql_table/tab.ts
new file mode 100644
index 0000000..177ce43
--- /dev/null
+++ b/ui/src/frontend/sql_table/tab.ts
@@ -0,0 +1,106 @@
+// Copyright (C) 2023 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import m from 'mithril';
+
+import {BottomTab, bottomTabRegistry, NewBottomTabArgs} from '../bottom_tab';
+import {copyToClipboard} from '../clipboard';
+import {Icons} from '../semantic_icons';
+import {Button} from '../widgets/button';
+import {DetailsShell} from '../widgets/details_shell';
+import {exists} from '../widgets/utils';
+
+import {SqlTableState} from './state';
+import {SqlTable} from './table';
+import {SqlTableDescription} from './table_description';
+
+interface SqlTableTabConfig {
+  table: SqlTableDescription;
+  displayName?: string;
+  filters?: string[];
+}
+
+export class SqlTableTab extends BottomTab<SqlTableTabConfig> {
+  static readonly kind = 'org.perfetto.SqlTableTab';
+
+  private state: SqlTableState;
+
+  constructor(args: NewBottomTabArgs) {
+    super(args);
+
+    this.state =
+        new SqlTableState(this.engine, this.config.table, this.config.filters);
+  }
+
+  static create(args: NewBottomTabArgs): SqlTableTab {
+    return new SqlTableTab(args);
+  }
+
+  viewTab() {
+    const range = this.state.getDisplayedRange();
+    const rowCount = this.state.getTotalRowCount();
+    const navigation = [
+      exists(range) && exists(rowCount) &&
+          `Showing rows ${range.from}-${range.to} of ${rowCount}`,
+      m(Button, {
+        icon: Icons.GoBack,
+        disabled: !this.state.canGoBack(),
+        onclick: () => this.state.goBack(),
+        minimal: true,
+      }),
+      m(Button, {
+        icon: Icons.GoForward,
+        disabled: !this.state.canGoForward(),
+        onclick: () => this.state.goForward(),
+        minimal: true,
+      }),
+    ];
+
+    return m(
+        DetailsShell,
+        {
+          title: 'Table',
+          description: this.config.displayName ?? this.config.table.name,
+          buttons: [
+            ...navigation,
+            m(Button, {
+              label: 'Copy SQL query',
+              onclick: () =>
+                  copyToClipboard(this.state.getNonPaginatedSQLQuery()),
+            }),
+            m(Button, {
+              label: 'Close',
+              onclick: () => this.close(),
+            }),
+          ],
+        },
+        m(SqlTable, {
+          state: this.state,
+        }));
+  }
+
+  renderTabCanvas() {}
+
+  getTitle(): string {
+    const rowCount = this.state.getTotalRowCount();
+    const rows = rowCount === undefined ? '' : `(${rowCount})`;
+    return `Table ${this.config.displayName ?? this.config.table.name} ${rows}`;
+  }
+
+  isLoading(): boolean {
+    return this.state.isLoading();
+  }
+}
+
+bottomTabRegistry.register(SqlTableTab);
diff --git a/ui/src/frontend/sql_table/table.ts b/ui/src/frontend/sql_table/table.ts
new file mode 100644
index 0000000..a42ad89
--- /dev/null
+++ b/ui/src/frontend/sql_table/table.ts
@@ -0,0 +1,165 @@
+// Copyright (C) 2023 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import m from 'mithril';
+
+import {EngineProxy} from '../../common/engine';
+import {Row} from '../../common/query_result';
+import {Anchor} from '../anchor';
+import {Icons} from '../semantic_icons';
+import {BasicTable} from '../widgets/basic_table';
+import {Button} from '../widgets/button';
+import {MenuDivider, MenuItem, PopupMenu2} from '../widgets/menu';
+
+import {ArgumentSelector} from './argument_selector';
+import {argColumn, Column, columnFromSqlTableColumn} from './column';
+import {renderCell} from './render_cell';
+import {SqlTableState} from './state';
+import {isArgSetIdColumn, SqlTableDescription} from './table_description';
+
+export interface SqlTableConfig {
+  readonly state: SqlTableState;
+}
+
+export class SqlTable implements m.ClassComponent<SqlTableConfig> {
+  private readonly table: SqlTableDescription;
+  private readonly engine: EngineProxy;
+
+  private state: SqlTableState;
+
+  constructor(vnode: m.Vnode<SqlTableConfig>) {
+    this.state = vnode.attrs.state;
+    this.table = this.state.table;
+    this.engine = this.state.engine;
+  }
+
+  renderFilters(): m.Children {
+    const filters: m.Child[] = [];
+    for (const filter of this.state.getFilters()) {
+      filters.push(m(Button, {
+        label: filter,
+        icon: 'close',
+        onclick: () => {
+          this.state.removeFilter(filter);
+        },
+      }));
+    }
+    return filters;
+  }
+
+  renderAddColumnOptions(addColumn: (column: Column) => void): m.Children {
+    // We do not want to add columns which already exist, so we track the
+    // columns which we are already showing here.
+    // TODO(altimin): Theoretically a single table can have two different
+    // arg_set_ids, so we should track (arg_set_id_column, arg_name) pairs here.
+    const existingColumns = new Set<string>();
+
+    for (const column of this.state.getSelectedColumns()) {
+      existingColumns.add(column.alias);
+    }
+
+    const result = [];
+    for (const column of this.table.columns) {
+      if (existingColumns.has(column.name)) continue;
+      if (isArgSetIdColumn(column)) {
+        result.push(
+            m(MenuItem,
+              {
+                label: column.name,
+              },
+              m(ArgumentSelector, {
+                engine: this.engine,
+                argSetId: column,
+                tableName: this.table.name,
+                filters: this.state.getFilters(),
+                alreadySelectedColumns: existingColumns,
+                onArgumentSelected: (argument: string) => {
+                  addColumn(argColumn(column, argument));
+                },
+              })));
+        continue;
+      }
+      result.push(m(MenuItem, {
+        label: column.name,
+        onclick: () => addColumn(
+            columnFromSqlTableColumn(column),
+            ),
+      }));
+    }
+    return result;
+  }
+
+  renderColumnHeader(column: Column, index: number) {
+    const sorted = this.state.isSortedBy(column);
+    const icon = sorted === 'ASC' ?
+        Icons.SortedAsc :
+        sorted === 'DESC' ? Icons.SortedDesc : Icons.ContextMenu;
+    return m(
+        PopupMenu2,
+        {
+          trigger: m(Anchor, {icon}, column.title),
+        },
+        sorted !== 'DESC' && m(MenuItem, {
+          label: 'Sort: highest first',
+          icon: Icons.SortedDesc,
+          onclick: () => {
+            this.state.sortBy({column, direction: 'DESC'});
+          },
+        }),
+        sorted !== 'ASC' && m(MenuItem, {
+          label: 'Sort: lowest first',
+          icon: Icons.SortedAsc,
+          onclick: () => {
+            this.state.sortBy({column, direction: 'ASC'});
+          },
+        }),
+        sorted !== undefined && m(MenuItem, {
+          label: 'Unsort',
+          icon: Icons.Close,
+          onclick: () => this.state.unsort(),
+        }),
+        this.state.getSelectedColumns().length > 1 && m(MenuItem, {
+          label: 'Hide',
+          icon: Icons.Hide,
+          onclick: () => this.state.hideColumnAtIndex(index),
+        }),
+        m(MenuDivider),
+        m(MenuItem,
+          {label: 'Add column', icon: Icons.AddColumn},
+          this.renderAddColumnOptions((column) => {
+            this.state.addColumn(column, index);
+          })),
+    );
+  }
+
+  view() {
+    const rows = this.state.getDisplayedRows();
+
+    return [
+      m('div', this.renderFilters()),
+      m(BasicTable, {
+        data: rows,
+        columns: this.state.getSelectedColumns().map(
+            (column, i) => ({
+              title: this.renderColumnHeader(column, i),
+              render: (row: Row) => renderCell(column, row, this.state),
+            })),
+      }),
+      this.state.getQueryError() !== undefined &&
+          m('.query-error', this.state.getQueryError()),
+    ];
+  }
+};
+
+export {SqlTableDescription};
diff --git a/ui/src/frontend/sql_table/table_description.ts b/ui/src/frontend/sql_table/table_description.ts
new file mode 100644
index 0000000..b3fa312
--- /dev/null
+++ b/ui/src/frontend/sql_table/table_description.ts
@@ -0,0 +1,97 @@
+// Copyright (C) 2023 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+
+// Definition of the SQL table to be displayed in the SQL table widget,
+// including the semantic definitions of the columns (e.g. timestamp
+// column which requires special formatting). Also note that some of the
+// columns require other columns for advanced display features (e.g. timestamp
+// and duration taken together define "a time range", which can be used for
+// additional filtering.
+
+export type DisplayConfig =
+    SliceIdDisplayConfig|Timestamp|Duration|ThreadDuration|ArgSetId;
+
+// Common properties for all columns.
+interface SqlTableColumnBase {
+  // Name of the column in the SQL table.
+  name: string;
+  // Display name of the column in the UI.
+  title?: string;
+  // Whether the column should be hidden by default.
+  startsHidden?: boolean;
+}
+
+export interface ArgSetIdColumn extends SqlTableColumnBase {
+  type: 'arg_set_id';
+}
+
+export interface RegularSqlTableColumn extends SqlTableColumnBase {
+  // Special rendering instructions for this column, including the list
+  // of additional columns required for the rendering.
+  display?: DisplayConfig;
+}
+
+export type SqlTableColumn = RegularSqlTableColumn|ArgSetIdColumn;
+
+export function isArgSetIdColumn(c: SqlTableColumn): c is ArgSetIdColumn {
+  return (c as {type?: string}).type === 'arg_set_id';
+}
+
+export interface SqlTableDescription {
+  readonly imports?: string[];
+  name: string;
+  columns: SqlTableColumn[];
+}
+
+// Additional columns needed to display the given column.
+export function dependendentColumns(display?: DisplayConfig): string[] {
+  switch (display?.type) {
+    case 'slice_id':
+      return [display.ts, display.dur, display.trackId];
+    default:
+      return [];
+  }
+}
+
+// Column displaying ids into the `slice` table. Requires the ts, dur and
+// track_id columns to be able to display the value, including the
+// "go-to-slice-on-click" functionality.
+export interface SliceIdDisplayConfig {
+  type: 'slice_id';
+  ts: string;
+  dur: string;
+  trackId: string;
+}
+
+// Column displaying timestamps.
+interface Timestamp {
+  type: 'timestamp';
+}
+
+// Column displaying durations.
+export interface Duration {
+  type: 'duration';
+}
+
+// Column displaying thread durations.
+export interface ThreadDuration {
+  type: 'thread_duration';
+}
+
+// Column corresponding to an arg_set_id. Will never be directly displayed,
+// but will allow the user select an argument to display from the arg_set.
+export interface ArgSetId {
+  type: 'arg_set_id';
+}
diff --git a/ui/src/frontend/sql_table/well_known_tables.ts b/ui/src/frontend/sql_table/well_known_tables.ts
new file mode 100644
index 0000000..4b503a4
--- /dev/null
+++ b/ui/src/frontend/sql_table/well_known_tables.ts
@@ -0,0 +1,114 @@
+// Copyright (C) 2023 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import {SqlTableDescription} from './table';
+
+const sliceTable: SqlTableDescription = {
+  imports: ['experimental.slices'],
+  name: 'experimental_slice_with_thread_and_process_info',
+  columns: [
+    {
+      name: 'id',
+      title: 'ID',
+      display: {
+        type: 'slice_id',
+        ts: 'ts',
+        dur: 'dur',
+        trackId: 'track_id',
+      },
+    },
+    {
+      name: 'ts',
+      title: 'Timestamp',
+      display: {
+        type: 'timestamp',
+      },
+    },
+    {
+      name: 'dur',
+      title: 'Duration',
+      display: {
+        type: 'duration',
+      },
+    },
+    {
+      name: 'thread_dur',
+      title: 'Thread duration',
+      display: {
+        type: 'thread_duration',
+      },
+    },
+    {
+      name: 'category',
+      title: 'Category',
+    },
+    {
+      name: 'name',
+      title: 'Name',
+    },
+    {
+      name: 'track_id',
+      title: 'Track ID',
+      startsHidden: true,
+    },
+    {
+      name: 'track_name',
+      title: 'Track name',
+      startsHidden: true,
+    },
+    {
+      name: 'thread_name',
+      title: 'Thread name',
+    },
+    {
+      name: 'utid',
+      startsHidden: true,
+    },
+    {
+      name: 'tid',
+    },
+    {
+      name: 'process_name',
+      title: 'Process name',
+    },
+    {
+      name: 'upid',
+      startsHidden: true,
+    },
+    {
+      name: 'pid',
+    },
+    {
+      name: 'depth',
+      title: 'Depth',
+      startsHidden: true,
+    },
+    {
+      name: 'parent_id',
+      title: 'Parent slice ID',
+      startsHidden: true,
+    },
+    {
+      name: 'arg_set_id',
+      title: 'Arg',
+      display: {
+        type: 'arg_set_id',
+      },
+    },
+  ],
+};
+
+export class SqlTables {
+  static readonly slice = sliceTable;
+}
diff --git a/ui/src/frontend/sql_utils.ts b/ui/src/frontend/sql_utils.ts
index 2bbcd0e..6ef80f6 100644
--- a/ui/src/frontend/sql_utils.ts
+++ b/ui/src/frontend/sql_utils.ts
@@ -12,10 +12,11 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
+import {EngineProxy} from '../common/engine';
+import {ColumnType, NUM} from '../common/query_result';
 import {SortDirection} from '../common/state';
-import {ColumnType} from '../common/query_result';
 
-interface OrderClause {
+export interface OrderClause {
   fieldName: string;
   direction?: SortDirection;
 }
@@ -81,7 +82,10 @@
   return n;
 }
 
-export function sqlValueToString(val: ColumnType): string {
+export function sqlValueToString(val: ColumnType): string;
+export function sqlValueToString(val?: ColumnType): string|undefined;
+export function sqlValueToString(val?: ColumnType): string|undefined {
+  if (val === undefined) return undefined;
   if (val instanceof Uint8Array) {
     return `<blob length=${val.length}>`;
   }
@@ -90,3 +94,17 @@
   }
   return val.toString();
 }
+
+export async function getTableRowCount(
+    engine: EngineProxy, tableName: string): Promise<number|undefined> {
+  const result =
+      await engine.query(`SELECT COUNT() as count FROM ${tableName}`);
+  if (result.numRows() === 0) {
+    return undefined;
+  }
+  return result
+      .firstRow({
+        count: NUM,
+      })
+      .count;
+}
diff --git a/ui/src/frontend/widgets/basic_table.ts b/ui/src/frontend/widgets/basic_table.ts
new file mode 100644
index 0000000..578b9d7
--- /dev/null
+++ b/ui/src/frontend/widgets/basic_table.ts
@@ -0,0 +1,56 @@
+// Copyright (C) 2023 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use size file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import m from 'mithril';
+
+export interface ColumnDescriptor<T> {
+  title: m.Children;
+  render: (row: T) => m.Children;
+}
+
+export interface TableAttrs<T> {
+  data: T[];
+  columns: ColumnDescriptor<T>[];
+}
+
+export class BasicTable implements m.ClassComponent<TableAttrs<any>> {
+  renderColumnHeader(
+      _vnode: m.Vnode<TableAttrs<any>>,
+      column: ColumnDescriptor<any>): m.Children {
+    return m('td', column.title);
+  }
+
+  view(vnode: m.Vnode<TableAttrs<any>>): m.Child {
+    const attrs = vnode.attrs;
+
+    return m(
+        'table.generic-table',
+        {
+          // TODO(altimin, stevegolton): this should be the default for
+          // generic-table, but currently it is overriden by
+          // .pf-details-shell .pf-content table, so specify this here for now.
+          style: {
+            'table-layout': 'auto',
+          },
+        },
+        m('thead',
+          m('tr.header',
+            attrs.columns.map(
+                (column) => this.renderColumnHeader(vnode, column)))),
+        attrs.data.map(
+            (row) =>
+                m('tr',
+                  attrs.columns.map((column) => m('td', column.render(row))))));
+  }
+}
diff --git a/ui/src/frontend/widgets/error.ts b/ui/src/frontend/widgets/error.ts
new file mode 100644
index 0000000..17c91df
--- /dev/null
+++ b/ui/src/frontend/widgets/error.ts
@@ -0,0 +1,21 @@
+// Copyright (C) 2023 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import m from 'mithril';
+
+export class Err implements m.Component {
+  view(vnode: m.Vnode) {
+    return m('.pf-error', vnode.children);
+  }
+}
diff --git a/ui/src/frontend/widgets/select.ts b/ui/src/frontend/widgets/select.ts
index 7fdbdd4..2ec2bc2 100644
--- a/ui/src/frontend/widgets/select.ts
+++ b/ui/src/frontend/widgets/select.ts
@@ -14,8 +14,16 @@
 
 import m from 'mithril';
 
+import {globals} from '../globals';
+
+import {Menu, MenuItem} from './menu';
+import {TextInput} from './text_input';
+import {exists} from './utils';
+
 export interface SelectAttrs {
   disabled?: boolean;
+  // Whether to show a search box. Defaults to false.
+  filterable?: boolean;
   [htmlAttrs: string]: any;
 }
 
@@ -29,3 +37,50 @@
         children);
   }
 }
+
+export interface FilterableSelectAttrs extends SelectAttrs {
+  values: string[];
+  onSelected: (value: string) => void;
+  maxDisplayedItems?: number;
+  autofocusInput?: boolean;
+}
+
+export class FilterableSelect implements
+    m.ClassComponent<FilterableSelectAttrs> {
+  searchText = '';
+
+  view({attrs}: m.CVnode<FilterableSelectAttrs>) {
+    const filteredValues = attrs.values.filter((name) => {
+      return name.toLowerCase().includes(this.searchText.toLowerCase());
+    });
+
+    const extraItems = exists(attrs.maxDisplayedItems) &&
+        Math.max(0, filteredValues.length - attrs.maxDisplayedItems);
+
+    // TODO(altimin): when the user presses enter and there is only one item,
+    // select the first one.
+    // MAYBE(altimin): when the user presses enter and there are multiple items,
+    // select the first one.
+    return m(
+        'div',
+        m('.pf-search-bar',
+          m(TextInput, {
+            autofocus: attrs.autofocusInput,
+            oninput: (event: Event) => {
+              const eventTarget = event.target as HTMLTextAreaElement;
+              this.searchText = eventTarget.value;
+              globals.rafScheduler.scheduleFullRedraw();
+            },
+            value: this.searchText,
+            placeholder: 'Filter options...',
+            extraClasses: 'pf-search-box',
+          }),
+          m(Menu,
+            ...filteredValues.map(
+                (value) => m(MenuItem, {
+                  label: value,
+                  onclick: () => attrs.onSelected(value),
+                }),
+                extraItems && m('i', `+${extraItems} more`)))));
+  }
+}
diff --git a/ui/src/frontend/widgets/timestamp.ts b/ui/src/frontend/widgets/timestamp.ts
index b945489..a138f17 100644
--- a/ui/src/frontend/widgets/timestamp.ts
+++ b/ui/src/frontend/widgets/timestamp.ts
@@ -57,7 +57,7 @@
   }
 }
 
-function renderTimecode(ts: TPTimestamp): m.Children {
+export function renderTimecode(ts: TPTimestamp): m.Children {
   const relTime = toDomainTime(ts);
   const {dhhmmss, millis, micros, nanos} = new Timecode(relTime);
   return [