Add simple dead code eliminator transform.

This CL adds a new transform, used by the SPIR-V Reader lower pass, to
do a simple dead code elimination pass over the module. To start this
pass will remove:

* Unused functions.
* Unused module scoped `__in`, `__out` and `private` vars.

The SPIR-V IR reader pass then uses this to remove any unused
Input/Output variables from the program.

Bug: 42250952
Change-Id: I9022248702cde50c020dc7724a609333172604b3
Reviewed-on: https://dawn-review.googlesource.com/c/dawn/+/246694
Reviewed-by: James Price <jrprice@google.com>
Commit-Queue: dan sinclair <dsinclair@chromium.org>
diff --git a/src/tint/lang/core/ir/module.cc b/src/tint/lang/core/ir/module.cc
index 73adfa7..a112b13 100644
--- a/src/tint/lang/core/ir/module.cc
+++ b/src/tint/lang/core/ir/module.cc
@@ -183,4 +183,9 @@
     return FunctionSorter<const Function>::SortFunctions(*this);
 }
 
+void Module::Destroy(Function* func) {
+    functions.EraseIf([&](const core::ir::Function* o) { return o == func; });
+    func->Destroy();
+}
+
 }  // namespace tint::core::ir
diff --git a/src/tint/lang/core/ir/module.h b/src/tint/lang/core/ir/module.h
index 15639d6..7100195 100644
--- a/src/tint/lang/core/ir/module.h
+++ b/src/tint/lang/core/ir/module.h
@@ -178,6 +178,10 @@
     /// @returns the functions in the module, in dependency order
     Vector<const Function*, 16> DependencyOrderedFunctions() const;
 
+    /// Removes `func` from the module and destroys it.
+    /// @param func the function to destroy
+    void Destroy(Function* func);
+
     /// The block allocator
     BlockAllocator<Block> blocks;
 
diff --git a/src/tint/lang/core/ir/transform/BUILD.bazel b/src/tint/lang/core/ir/transform/BUILD.bazel
index 1e1e14e..b801ad0 100644
--- a/src/tint/lang/core/ir/transform/BUILD.bazel
+++ b/src/tint/lang/core/ir/transform/BUILD.bazel
@@ -48,6 +48,7 @@
     "builtin_polyfill.cc",
     "combine_access_instructions.cc",
     "conversion_polyfill.cc",
+    "dead_code_elimination.cc",
     "demote_to_helper.cc",
     "direct_variable_access.cc",
     "multiplanar_external_texture.cc",
@@ -77,6 +78,7 @@
     "builtin_polyfill.h",
     "combine_access_instructions.h",
     "conversion_polyfill.h",
+    "dead_code_elimination.h",
     "demote_to_helper.h",
     "direct_variable_access.h",
     "multiplanar_external_texture.h",
@@ -134,6 +136,7 @@
     "builtin_polyfill_test.cc",
     "combine_access_instructions_test.cc",
     "conversion_polyfill_test.cc",
+    "dead_code_elimination_test.cc",
     "demote_to_helper_test.cc",
     "direct_variable_access_test.cc",
     "helper_test.h",
diff --git a/src/tint/lang/core/ir/transform/BUILD.cmake b/src/tint/lang/core/ir/transform/BUILD.cmake
index de4583d..3f2abf8 100644
--- a/src/tint/lang/core/ir/transform/BUILD.cmake
+++ b/src/tint/lang/core/ir/transform/BUILD.cmake
@@ -57,6 +57,8 @@
   lang/core/ir/transform/combine_access_instructions.h
   lang/core/ir/transform/conversion_polyfill.cc
   lang/core/ir/transform/conversion_polyfill.h
+  lang/core/ir/transform/dead_code_elimination.cc
+  lang/core/ir/transform/dead_code_elimination.h
   lang/core/ir/transform/demote_to_helper.cc
   lang/core/ir/transform/demote_to_helper.h
   lang/core/ir/transform/direct_variable_access.cc
@@ -135,6 +137,7 @@
   lang/core/ir/transform/builtin_polyfill_test.cc
   lang/core/ir/transform/combine_access_instructions_test.cc
   lang/core/ir/transform/conversion_polyfill_test.cc
+  lang/core/ir/transform/dead_code_elimination_test.cc
   lang/core/ir/transform/demote_to_helper_test.cc
   lang/core/ir/transform/direct_variable_access_test.cc
   lang/core/ir/transform/helper_test.h
@@ -195,6 +198,7 @@
   lang/core/ir/transform/builtin_polyfill_fuzz.cc
   lang/core/ir/transform/combine_access_instructions_fuzz.cc
   lang/core/ir/transform/conversion_polyfill_fuzz.cc
+  lang/core/ir/transform/dead_code_elimination_fuzz.cc
   lang/core/ir/transform/demote_to_helper_fuzz.cc
   lang/core/ir/transform/direct_variable_access_fuzz.cc
   lang/core/ir/transform/multiplanar_external_texture_fuzz.cc
diff --git a/src/tint/lang/core/ir/transform/BUILD.gn b/src/tint/lang/core/ir/transform/BUILD.gn
index 3e4098f..cdf8820 100644
--- a/src/tint/lang/core/ir/transform/BUILD.gn
+++ b/src/tint/lang/core/ir/transform/BUILD.gn
@@ -63,6 +63,8 @@
     "combine_access_instructions.h",
     "conversion_polyfill.cc",
     "conversion_polyfill.h",
+    "dead_code_elimination.cc",
+    "dead_code_elimination.h",
     "demote_to_helper.cc",
     "demote_to_helper.h",
     "direct_variable_access.cc",
@@ -135,6 +137,7 @@
       "builtin_polyfill_test.cc",
       "combine_access_instructions_test.cc",
       "conversion_polyfill_test.cc",
+      "dead_code_elimination_test.cc",
       "demote_to_helper_test.cc",
       "direct_variable_access_test.cc",
       "helper_test.h",
@@ -190,6 +193,7 @@
     "builtin_polyfill_fuzz.cc",
     "combine_access_instructions_fuzz.cc",
     "conversion_polyfill_fuzz.cc",
+    "dead_code_elimination_fuzz.cc",
     "demote_to_helper_fuzz.cc",
     "direct_variable_access_fuzz.cc",
     "multiplanar_external_texture_fuzz.cc",
diff --git a/src/tint/lang/core/ir/transform/dead_code_elimination.cc b/src/tint/lang/core/ir/transform/dead_code_elimination.cc
new file mode 100644
index 0000000..df909d4
--- /dev/null
+++ b/src/tint/lang/core/ir/transform/dead_code_elimination.cc
@@ -0,0 +1,115 @@
+// Copyright 2025 The Dawn & Tint Authors
+//
+// Redistribution and use in source and binary forms, with or without
+// modification, are permitted provided that the following conditions are met:
+//
+// 1. Redistributions of source code must retain the above copyright notice, this
+//    list of conditions and the following disclaimer.
+//
+// 2. Redistributions in binary form must reproduce the above copyright notice,
+//    this list of conditions and the following disclaimer in the documentation
+//    and/or other materials provided with the distribution.
+//
+// 3. Neither the name of the copyright holder nor the names of its
+//    contributors may be used to endorse or promote products derived from
+//    this software without specific prior written permission.
+//
+// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+// AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+// IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+// DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
+// FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+// DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+// SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+// CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+// OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+// OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+#include "src/tint/lang/core/ir/transform/dead_code_elimination.h"
+
+#include <vector>
+
+#include "src/tint/lang/core/ir/builder.h"
+#include "src/tint/lang/core/ir/module.h"
+#include "src/tint/lang/core/ir/validator.h"
+#include "src/tint/utils/ice/ice.h"
+
+using namespace tint::core::fluent_types;     // NOLINT
+using namespace tint::core::number_suffixes;  // NOLINT
+
+namespace tint::core::ir::transform {
+
+namespace {
+
+/// PIMPL state for the transform.
+struct State {
+    /// The IR module.
+    Module& ir_;
+
+    /// The IR builder.
+    Builder b_{ir_};
+
+    /// Process the module.
+    void Process() {
+        auto funcs = ir_.DependencyOrderedFunctions();
+        for (auto iter = funcs.rbegin(); iter != funcs.rend(); ++iter) {
+            auto func = *iter;
+            if (func->IsEntryPoint()) {
+                continue;
+            }
+
+            bool used = false;
+            for (auto usage : func->UsagesUnsorted()) {
+                if (usage->instruction->Is<core::ir::Call>()) {
+                    used = true;
+                    break;
+                }
+            }
+
+            if (!used) {
+                ir_.Destroy(func);
+            }
+        }
+
+        // Find any unused vars, do this after removing functions in case the usage was in a
+        // function which is unused.
+        for (auto* inst : *ir_.root_block) {
+            if (inst->Result()->IsUsed()) {
+                continue;
+            }
+
+            // Only want to process `var`s
+            auto* var = inst->As<core::ir::Var>();
+            if (!var) {
+                continue;
+            }
+
+            auto* ptr = var->Result()->Type()->As<core::type::Pointer>();
+            TINT_ASSERT(ptr);
+
+            auto space = ptr->AddressSpace();
+            if (space != core::AddressSpace::kOut && space != core::AddressSpace::kIn &&
+                space != core::AddressSpace::kPrivate) {
+                continue;
+            }
+
+            inst->Destroy();
+        }
+    }
+};
+
+}  // namespace
+
+Result<SuccessType> DeadCodeElimination(Module& ir) {
+    auto result =
+        ValidateAndDumpIfNeeded(ir, "core.DeadCodeElimination", kDeadCodeEliminationCapabilities);
+    if (result != Success) {
+        return result;
+    }
+
+    State{ir}.Process();
+
+    return Success;
+}
+
+}  // namespace tint::core::ir::transform
diff --git a/src/tint/lang/core/ir/transform/dead_code_elimination.h b/src/tint/lang/core/ir/transform/dead_code_elimination.h
new file mode 100644
index 0000000..fdf7fc9
--- /dev/null
+++ b/src/tint/lang/core/ir/transform/dead_code_elimination.h
@@ -0,0 +1,61 @@
+// Copyright 2025 The Dawn & Tint Authors
+//
+// Redistribution and use in source and binary forms, with or without
+// modification, are permitted provided that the following conditions are met:
+//
+// 1. Redistributions of source code must retain the above copyright notice, this
+//    list of conditions and the following disclaimer.
+//
+// 2. Redistributions in binary form must reproduce the above copyright notice,
+//    this list of conditions and the following disclaimer in the documentation
+//    and/or other materials provided with the distribution.
+//
+// 3. Neither the name of the copyright holder nor the names of its
+//    contributors may be used to endorse or promote products derived from
+//    this software without specific prior written permission.
+//
+// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+// AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+// IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+// DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
+// FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+// DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+// SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+// CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+// OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+// OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+#ifndef SRC_TINT_LANG_CORE_IR_TRANSFORM_DEAD_CODE_ELIMINATION_H_
+#define SRC_TINT_LANG_CORE_IR_TRANSFORM_DEAD_CODE_ELIMINATION_H_
+
+#include "src/tint/lang/core/ir/validator.h"
+#include "src/tint/utils/result.h"
+
+// Forward declarations.
+namespace tint::core::ir {
+class Module;
+}
+
+namespace tint::core::ir::transform {
+
+/// The capabilities that the transform can support.
+const core::ir::Capabilities kDeadCodeEliminationCapabilities{
+    core::ir::Capability::kAllowOverrides,
+    core::ir::Capability::kAllowVectorElementPointer,
+    core::ir::Capability::kAllowPhonyInstructions,
+    core::ir::Capability::kAllowUnannotatedModuleIOVariables,
+};
+
+/// DeadCodeElimination is a transform that removes dead code from the given IR module.
+///
+/// Currently the eliminator will try to remove:
+///  * Unused functions.
+///  * Unused `private`, `__in` and `__out` module scoped variables.
+///
+/// @param module the module to transform
+/// @returns success or failure
+Result<SuccessType> DeadCodeElimination(Module& module);
+
+}  // namespace tint::core::ir::transform
+
+#endif  // SRC_TINT_LANG_CORE_IR_TRANSFORM_DEAD_CODE_ELIMINATION_H_
diff --git a/src/tint/lang/core/ir/transform/dead_code_elimination_fuzz.cc b/src/tint/lang/core/ir/transform/dead_code_elimination_fuzz.cc
new file mode 100644
index 0000000..ca0de1b
--- /dev/null
+++ b/src/tint/lang/core/ir/transform/dead_code_elimination_fuzz.cc
@@ -0,0 +1,44 @@
+// Copyright 2025 The Dawn & Tint Authors
+//
+// Redistribution and use in source and binary forms, with or without
+// modification, are permitted provided that the following conditions are met:
+//
+// 1. Redistributions of source code must retain the above copyright notice, this
+//    list of conditions and the following disclaimer.
+//
+// 2. Redistributions in binary form must reproduce the above copyright notice,
+//    this list of conditions and the following disclaimer in the documentation
+//    and/or other materials provided with the distribution.
+//
+// 3. Neither the name of the copyright holder nor the names of its
+//    contributors may be used to endorse or promote products derived from
+//    this software without specific prior written permission.
+//
+// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+// AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+// IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+// DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
+// FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+// DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+// SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+// CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+// OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+// OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+#include "src/tint/lang/core/ir/transform/dead_code_elimination.h"
+
+#include "src/tint/cmd/fuzz/ir/fuzz.h"
+#include "src/tint/lang/core/ir/validator.h"
+
+namespace tint::core::ir::transform {
+namespace {
+
+Result<SuccessType> DeadCodeEliminationFuzzer(Module& ir, const fuzz::ir::Context&) {
+    return DeadCodeElimination(ir);
+}
+
+}  // namespace
+}  // namespace tint::core::ir::transform
+
+TINT_IR_MODULE_FUZZER(tint::core::ir::transform::DeadCodeEliminationFuzzer,
+                      tint::core::ir::transform::kDeadCodeEliminationCapabilities);
diff --git a/src/tint/lang/core/ir/transform/dead_code_elimination_test.cc b/src/tint/lang/core/ir/transform/dead_code_elimination_test.cc
new file mode 100644
index 0000000..f3e02c2
--- /dev/null
+++ b/src/tint/lang/core/ir/transform/dead_code_elimination_test.cc
@@ -0,0 +1,383 @@
+// Copyright 2025 The Dawn & Tint Authors
+//
+// Redistribution and use in source and binary forms, with or without
+// modification, are permitted provided that the following conditions are met:
+//
+// 1. Redistributions of source code must retain the above copyright notice, this
+//    list of conditions and the following disclaimer.
+//
+// 2. Redistributions in binary form must reproduce the above copyright notice,
+//    this list of conditions and the following disclaimer in the documentation
+//    and/or other materials provided with the distribution.
+//
+// 3. Neither the name of the copyright holder nor the names of its
+//    contributors may be used to endorse or promote products derived from
+//    this software without specific prior written permission.
+//
+// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+// AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+// IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+// DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
+// FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+// DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+// SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+// CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+// OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+// OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+#include "src/tint/lang/core/ir/transform/dead_code_elimination.h"
+
+#include <utility>
+
+#include "src/tint/lang/core/ir/transform/helper_test.h"
+
+namespace tint::core::ir::transform {
+namespace {
+
+using namespace tint::core::fluent_types;     // NOLINT
+using namespace tint::core::number_suffixes;  // NOLINT
+
+using IR_DeadCodeEliminationTest = TransformTest;
+
+TEST_F(IR_DeadCodeEliminationTest, NoModify) {
+    capabilities = Capability::kAllowUnannotatedModuleIOVariables;
+
+    auto* buffer = b.Var("buffer", ty.ptr(core::AddressSpace::kOut, ty.i32()));
+    mod.root_block->Append(buffer);
+
+    auto* used = b.Function("used", ty.f32());
+    b.Append(used->Block(), [&] { b.Return(used, 0.5_f); });
+
+    auto* ep = b.Function("ep", ty.f32(), Function::PipelineStage::kFragment);
+    ep->SetReturnLocation(0_u);
+
+    b.Append(ep->Block(), [&] {  //
+        b.Store(buffer, 42_i);
+        b.Return(ep, b.Call(ty.f32(), used));
+    });
+
+    auto* src = R"(
+$B1: {  # root
+  %buffer:ptr<__out, i32, read_write> = var undef
+}
+
+%used = func():f32 {
+  $B2: {
+    ret 0.5f
+  }
+}
+%ep = @fragment func():f32 [@location(0)] {
+  $B3: {
+    store %buffer, 42i
+    %4:f32 = call %used
+    ret %4
+  }
+}
+)";
+    EXPECT_EQ(src, str());
+
+    auto* expect = src;
+
+    Run(DeadCodeElimination);
+
+    EXPECT_EQ(expect, str());
+}
+
+TEST_F(IR_DeadCodeEliminationTest, RemoveFunction) {
+    capabilities = Capabilities{
+        Capability::kAllowUnannotatedModuleIOVariables,
+        Capability::kAllowPhonyInstructions,
+    };
+
+    auto* buffer = b.Var("buffer", ty.ptr(core::AddressSpace::kIn, ty.i32()));
+    mod.root_block->Append(buffer);
+
+    auto* unused = b.Function("unused", ty.f32());
+    b.Append(unused->Block(), [&] { b.Return(unused, 0.5_f); });
+
+    auto* ep = b.Function("ep", ty.f32(), Function::PipelineStage::kFragment);
+    ep->SetReturnLocation(0_u);
+
+    b.Append(ep->Block(), [&] {  //
+        b.Phony(b.Load(buffer));
+        b.Return(ep, 0.5_f);
+    });
+
+    auto* src = R"(
+$B1: {  # root
+  %buffer:ptr<__in, i32, read> = var undef
+}
+
+%unused = func():f32 {
+  $B2: {
+    ret 0.5f
+  }
+}
+%ep = @fragment func():f32 [@location(0)] {
+  $B3: {
+    %4:i32 = load %buffer
+    undef = phony %4
+    ret 0.5f
+  }
+}
+)";
+    EXPECT_EQ(src, str());
+
+    auto* expect = R"(
+$B1: {  # root
+  %buffer:ptr<__in, i32, read> = var undef
+}
+
+%ep = @fragment func():f32 [@location(0)] {
+  $B2: {
+    %3:i32 = load %buffer
+    undef = phony %3
+    ret 0.5f
+  }
+}
+)";
+
+    Run(DeadCodeElimination);
+
+    EXPECT_EQ(expect, str());
+}
+
+TEST_F(IR_DeadCodeEliminationTest, RemoveFunction_Recursive) {
+    auto* buffer = b.Var("buffer", ty.ptr(core::AddressSpace::kPrivate, ty.i32()));
+    mod.root_block->Append(buffer);
+
+    auto* unused2 = b.Function("unused2", ty.f32());
+    b.Append(unused2->Block(), [&] { b.Return(unused2, 0.5_f); });
+
+    auto* unused = b.Function("unused", ty.f32());
+    b.Append(unused->Block(), [&] { b.Return(unused, b.Call(ty.f32(), unused2)); });
+
+    auto* ep = b.Function("ep", ty.f32(), Function::PipelineStage::kFragment);
+    ep->SetReturnLocation(0_u);
+
+    b.Append(ep->Block(), [&] {  //
+        b.Store(buffer, 42_i);
+        b.Return(ep, 0.5_f);
+    });
+
+    auto* src = R"(
+$B1: {  # root
+  %buffer:ptr<private, i32, read_write> = var undef
+}
+
+%unused2 = func():f32 {
+  $B2: {
+    ret 0.5f
+  }
+}
+%unused = func():f32 {
+  $B3: {
+    %4:f32 = call %unused2
+    ret %4
+  }
+}
+%ep = @fragment func():f32 [@location(0)] {
+  $B4: {
+    store %buffer, 42i
+    ret 0.5f
+  }
+}
+)";
+    EXPECT_EQ(src, str());
+
+    auto* expect = R"(
+$B1: {  # root
+  %buffer:ptr<private, i32, read_write> = var undef
+}
+
+%ep = @fragment func():f32 [@location(0)] {
+  $B2: {
+    store %buffer, 42i
+    ret 0.5f
+  }
+}
+)";
+
+    Run(DeadCodeElimination);
+
+    EXPECT_EQ(expect, str());
+}
+
+TEST_F(IR_DeadCodeEliminationTest, RemoveVarIn_Nested) {
+    auto* buffer = b.Var("buffer", ty.ptr(core::AddressSpace::kIn, ty.i32()));
+    mod.root_block->Append(buffer);
+
+    auto* unused2 = b.Function("unused2", ty.f32());
+    b.Append(unused2->Block(), [&] {
+        b.Phony(b.Load(buffer));
+        b.Return(unused2, 0.5_f);
+    });
+
+    auto* unused = b.Function("unused", ty.f32());
+    b.Append(unused->Block(), [&] { b.Return(unused, b.Call(ty.f32(), unused2)); });
+
+    auto* ep = b.Function("ep", ty.f32(), Function::PipelineStage::kFragment);
+    ep->SetReturnLocation(0_u);
+
+    b.Append(ep->Block(), [&] {  //
+        b.Return(ep, 0.5_f);
+    });
+
+    auto* src = R"(
+$B1: {  # root
+  %buffer:ptr<__in, i32, read> = var undef
+}
+
+%unused2 = func():f32 {
+  $B2: {
+    %3:i32 = load %buffer
+    undef = phony %3
+    ret 0.5f
+  }
+}
+%unused = func():f32 {
+  $B3: {
+    %5:f32 = call %unused2
+    ret %5
+  }
+}
+%ep = @fragment func():f32 [@location(0)] {
+  $B4: {
+    ret 0.5f
+  }
+}
+)";
+    EXPECT_EQ(src, str());
+
+    auto* expect = R"(
+%ep = @fragment func():f32 [@location(0)] {
+  $B1: {
+    ret 0.5f
+  }
+}
+)";
+
+    Run(DeadCodeElimination);
+
+    EXPECT_EQ(expect, str());
+}
+
+TEST_F(IR_DeadCodeEliminationTest, RemoveVarOut_Nested) {
+    auto* buffer =
+        b.Var("buffer", ty.ptr(core::AddressSpace::kOut, ty.i32(), core::Access::kWrite));
+    mod.root_block->Append(buffer);
+
+    auto* unused2 = b.Function("unused2", ty.f32());
+    b.Append(unused2->Block(), [&] {
+        b.Store(buffer, 42_i);
+        b.Return(unused2, 0.5_f);
+    });
+
+    auto* unused = b.Function("unused", ty.f32());
+    b.Append(unused->Block(), [&] { b.Return(unused, b.Call(ty.f32(), unused2)); });
+
+    auto* ep = b.Function("ep", ty.f32(), Function::PipelineStage::kFragment);
+    ep->SetReturnLocation(0_u);
+
+    b.Append(ep->Block(), [&] {  //
+        b.Return(ep, 0.5_f);
+    });
+
+    auto* src = R"(
+$B1: {  # root
+  %buffer:ptr<__out, i32, write> = var undef
+}
+
+%unused2 = func():f32 {
+  $B2: {
+    store %buffer, 42i
+    ret 0.5f
+  }
+}
+%unused = func():f32 {
+  $B3: {
+    %4:f32 = call %unused2
+    ret %4
+  }
+}
+%ep = @fragment func():f32 [@location(0)] {
+  $B4: {
+    ret 0.5f
+  }
+}
+)";
+    EXPECT_EQ(src, str());
+
+    auto* expect = R"(
+%ep = @fragment func():f32 [@location(0)] {
+  $B1: {
+    ret 0.5f
+  }
+}
+)";
+
+    Run(DeadCodeElimination);
+
+    EXPECT_EQ(expect, str());
+}
+
+TEST_F(IR_DeadCodeEliminationTest, RemoveVarPrivate_Nested) {
+    auto* buffer = b.Var("buffer", ty.ptr(core::AddressSpace::kPrivate, ty.i32()));
+    mod.root_block->Append(buffer);
+
+    auto* unused2 = b.Function("unused2", ty.f32());
+    b.Append(unused2->Block(), [&] {
+        b.Store(buffer, 42_i);
+        b.Return(unused2, 0.5_f);
+    });
+
+    auto* unused = b.Function("unused", ty.f32());
+    b.Append(unused->Block(), [&] { b.Return(unused, b.Call(ty.f32(), unused2)); });
+
+    auto* ep = b.Function("ep", ty.f32(), Function::PipelineStage::kFragment);
+    ep->SetReturnLocation(0_u);
+
+    b.Append(ep->Block(), [&] {  //
+        b.Return(ep, 0.5_f);
+    });
+
+    auto* src = R"(
+$B1: {  # root
+  %buffer:ptr<private, i32, read_write> = var undef
+}
+
+%unused2 = func():f32 {
+  $B2: {
+    store %buffer, 42i
+    ret 0.5f
+  }
+}
+%unused = func():f32 {
+  $B3: {
+    %4:f32 = call %unused2
+    ret %4
+  }
+}
+%ep = @fragment func():f32 [@location(0)] {
+  $B4: {
+    ret 0.5f
+  }
+}
+)";
+    EXPECT_EQ(src, str());
+
+    auto* expect = R"(
+%ep = @fragment func():f32 [@location(0)] {
+  $B1: {
+    ret 0.5f
+  }
+}
+)";
+
+    Run(DeadCodeElimination);
+
+    EXPECT_EQ(expect, str());
+}
+
+}  // namespace
+}  // namespace tint::core::ir::transform
diff --git a/src/tint/lang/core/ir/validator.cc b/src/tint/lang/core/ir/validator.cc
index 98816df..4248682 100644
--- a/src/tint/lang/core/ir/validator.cc
+++ b/src/tint/lang/core/ir/validator.cc
@@ -2707,7 +2707,8 @@
     }
 
     if (var->Block() == mod_.root_block) {
-        if (mv->AddressSpace() == AddressSpace::kIn || mv->AddressSpace() == AddressSpace::kOut) {
+        if ((mv->AddressSpace() == AddressSpace::kIn || mv->AddressSpace() == AddressSpace::kOut) &&
+            !capabilities_.Contains(Capability::kAllowUnannotatedModuleIOVariables)) {
             auto result = ValidateShaderIOAnnotations(var->Result()->Type(), var->BindingPoint(),
                                                       var->Attributes(), "module scope variable");
             if (result != Success) {
@@ -2759,8 +2760,10 @@
     const core::IOAttributes& attr,
     const std::string& target_str) {
     EnumSet<IOAnnotation> annotations;
+
     // Since there is no entries in the set at this point, this should never fail.
     TINT_ASSERT(AddIOAnnotationsFromIOAttributes(annotations, attr) == Success);
+
     if (binding_point.has_value()) {
         annotations.Add(IOAnnotation::kBindingPoint);
     }
diff --git a/src/tint/lang/core/ir/validator.h b/src/tint/lang/core/ir/validator.h
index 99f46da..34ec2cc 100644
--- a/src/tint/lang/core/ir/validator.h
+++ b/src/tint/lang/core/ir/validator.h
@@ -75,6 +75,8 @@
     /// Allows binding points to be non-unique. Used after BindingRemapper is
     /// invoked by MSL & GLSL backends.
     kAllowDuplicateBindings,
+    /// Allows module scope `var`s to exist without an IO annotation
+    kAllowUnannotatedModuleIOVariables,
 };
 
 /// Capabilities is a set of Capability
diff --git a/src/tint/lang/spirv/reader/lower/lower.cc b/src/tint/lang/spirv/reader/lower/lower.cc
index 4870fbe..feda9e1 100644
--- a/src/tint/lang/spirv/reader/lower/lower.cc
+++ b/src/tint/lang/spirv/reader/lower/lower.cc
@@ -27,6 +27,7 @@
 
 #include "src/tint/lang/spirv/reader/lower/lower.h"
 
+#include "src/tint/lang/core/ir/transform/dead_code_elimination.h"
 #include "src/tint/lang/core/ir/transform/remove_terminator_args.h"
 #include "src/tint/lang/core/ir/validator.h"
 #include "src/tint/lang/spirv/reader/lower/atomics.h"
@@ -46,6 +47,7 @@
         }                                \
     } while (false)
 
+    RUN_TRANSFORM(core::ir::transform::DeadCodeElimination, mod);
     RUN_TRANSFORM(lower::VectorElementPointer, mod);
     RUN_TRANSFORM(lower::ShaderIO, mod);
     RUN_TRANSFORM(lower::Builtins, mod);