Skip to content

Conversation

@Karthikdhondi
Copy link
Contributor

@Karthikdhondi Karthikdhondi commented Sep 23, 2025

…ositive form in --help

This wires the Clang driver boolean flag and forwards the negative form to LLVM as '-mllvm -no-inline-functions-called-once'. The positive form is exposed in --help (no backend flag forwarded). Default behavior is unchanged unless the flag is passed.

LLVM side: add the NoInlineFuncCalledOnce pass and thread the flag through PassBuilder pipelines.

Tests:

  • clang/test/Driver/fno_inline_functions_called_once.c
  • clang/test/CodeGen/no_inline_called_once.c

Also drops obsolete 'unsupported flag' expectations in clang/test/Driver/clang_f_opts.c.

Motivation

-fno-inline-functions-called-once lets users keep TU-local, single-caller helpers as real calls even under -O2/-O3, preserving useful function boundaries without disabling other optimizations.

Why this helps

- **Clearer debugging & reports:** Tiny helper frames often vanish when inlined, making ASan/UBSan/crash stacks point at the caller. Keeping the call preserves a distinct frame and symbol.
- **Works with probes & profilers:** Tools that hook function entry (uprobes/DTrace/eBPF, perf) need a stable entry address; inlining removes it.
- **Function-level coverage/profiling:** Some workflows aggregate by callee; inlining collapses data into the caller and adds noise across builds.
- **Large / third-party code:** Sprinkling __attribute__((noinline)) is invasive and brittle as call counts change. A build switch applies the policy uniformly.
- **Reproducibility in low-level code:** Inlining can shift register/stack layout and timing; keeping tiny helpers uninlined can stabilize repros while the rest remains optimized.

Scope & risk

- Opt-in only (off by default).
- Narrow effect: Only TU-local functions called exactly once are affected; other inlining heuristics are unchanged.

Ecosystem parity

- GCC provides the same toggle (-f[no-]inline-functions-called-once), so this aligns Clang/LLVM with existing expectations.

@llvmbot llvmbot added clang Clang issues not falling into any other category clang:driver 'clang' and 'clang++' user-facing binaries. Not 'clang-cl' llvm:transforms labels Sep 23, 2025
@llvmbot
Copy link
Member

llvmbot commented Sep 23, 2025

@llvm/pr-subscribers-llvm-transforms

@llvm/pr-subscribers-clang-driver

Author: kd0608 (Karthikdhondi)

Changes

…ositive form in --help

This wires the Clang driver boolean flag and forwards the negative form to LLVM as '-mllvm -no-inline-functions-called-once'. The positive form is exposed in --help (no backend flag forwarded). Default behavior is unchanged unless the flag is passed.

LLVM side: add the NoInlineFuncCalledOnce pass and thread the flag through PassBuilder pipelines.

Tests:

  • clang/test/Driver/fno_inline_functions_called_once.c
  • clang/test/CodeGen/no_inline_called_once.c

Also drops obsolete 'unsupported flag' expectations in clang/test/Driver/clang_f_opts.c.


Full diff: https://github.com/llvm/llvm-project/pull/160343.diff

9 Files Affected:

  • (modified) clang/include/clang/Driver/Options.td (+6-2)
  • (modified) clang/lib/Driver/ToolChains/Clang.cpp (+6)
  • (added) clang/test/CodeGen/no-inline-func-called-once.c (+32)
  • (modified) clang/test/Driver/clang_f_opts.c (-3)
  • (added) clang/test/Driver/fno-inline-functions-called-once.c (+20)
  • (added) llvm/include/llvm/Transforms/IPO/NoInlineFuncCalledOnce.h (+18)
  • (modified) llvm/lib/Passes/PassBuilderPipelines.cpp (+19)
  • (modified) llvm/lib/Transforms/IPO/CMakeLists.txt (+1)
  • (added) llvm/lib/Transforms/IPO/NoInlineFuncCalledOnce.cpp (+62)
diff --git a/clang/include/clang/Driver/Options.td b/clang/include/clang/Driver/Options.td
index 16e1c396fedbe..6c6d5f8455d4f 100644
--- a/clang/include/clang/Driver/Options.td
+++ b/clang/include/clang/Driver/Options.td
@@ -6959,8 +6959,12 @@ defm merge_constants : BooleanFFlag<"merge-constants">, Group<clang_ignored_gcc_
 defm modulo_sched : BooleanFFlag<"modulo-sched">, Group<clang_ignored_gcc_optimization_f_Group>;
 defm modulo_sched_allow_regmoves : BooleanFFlag<"modulo-sched-allow-regmoves">,
     Group<clang_ignored_gcc_optimization_f_Group>;
-defm inline_functions_called_once : BooleanFFlag<"inline-functions-called-once">,
-    Group<clang_ignored_gcc_optimization_f_Group>;
+defm inline_functions_called_once
+  : BooleanFFlag<"inline-functions-called-once">,
+    Group<f_clang_Group>,
+    Visibility<[ClangOption, CC1Option]>,
+    HelpText<"Control inlining of TU-local functions called exactly once "
+             "(use -fno-inline-functions-called-once to inhibit it)">;
 def finline_limit_EQ : Joined<["-"], "finline-limit=">, Group<clang_ignored_gcc_optimization_f_Group>;
 defm finline_limit : BooleanFFlag<"inline-limit">, Group<clang_ignored_gcc_optimization_f_Group>;
 defm inline_small_functions : BooleanFFlag<"inline-small-functions">,
diff --git a/clang/lib/Driver/ToolChains/Clang.cpp b/clang/lib/Driver/ToolChains/Clang.cpp
index f67454ee517bd..78f2f209b7165 100644
--- a/clang/lib/Driver/ToolChains/Clang.cpp
+++ b/clang/lib/Driver/ToolChains/Clang.cpp
@@ -7266,6 +7266,12 @@ void Clang::ConstructJob(Compilation &C, const JobAction &JA,
 
   Args.AddLastArg(CmdArgs, options::OPT_finline_max_stacksize_EQ);
 
+  // Forward -fno-inline-functions-called-once to LLVM so the pass is enabled.
+  if (Args.hasArg(options::OPT_fno_inline_functions_called_once)) {
+    CmdArgs.push_back("-mllvm");
+    CmdArgs.push_back("-no-inline-functions-called-once");
+  }
+
   // FIXME: Find a better way to determine whether we are in C++20.
   bool HaveCxx20 =
       Std &&
diff --git a/clang/test/CodeGen/no-inline-func-called-once.c b/clang/test/CodeGen/no-inline-func-called-once.c
new file mode 100644
index 0000000000000..92cdb57297739
--- /dev/null
+++ b/clang/test/CodeGen/no-inline-func-called-once.c
@@ -0,0 +1,32 @@
+// REQUIRES: x86-registered-target
+// RUN: %clang -O1 -S -emit-llvm %s -fno-inline-functions-called-once -o - | FileCheck %s --check-prefix=NOINLINE
+
+// We verify three things:
+//   1) There is a surviving call to bad_function (so it wasn’t inlined).
+//   2) bad_function’s definition exists and carries an attribute group id.
+//   3) That attribute group includes 'noinline'.
+
+// The call is earlier in the IR than the callee/attributes, so use -DAG for the
+// first two checks to avoid order constraints, then pin the attributes match.
+
+// NOINLINE-DAG: call{{.*}} @bad_function{{.*}}
+// NOINLINE-DAG: define internal{{.*}} @bad_function{{.*}} #[[ATTR:[0-9]+]]
+// NOINLINE: attributes #[[ATTR]] = { {{.*}}noinline{{.*}} }
+
+volatile int G;
+
+static void bad_function(void) {
+  // Volatile side effect ensures the call can’t be DCE’d.
+  G++;
+}
+
+static void test(void) {
+  // Exactly one TU-local caller of bad_function.
+  bad_function();
+}
+
+int main(void) {
+  // Make the caller reachable so it survives global DCE.
+  test();
+  return 0;
+}
diff --git a/clang/test/Driver/clang_f_opts.c b/clang/test/Driver/clang_f_opts.c
index bdeb747aa66a3..39726ac78140c 100644
--- a/clang/test/Driver/clang_f_opts.c
+++ b/clang/test/Driver/clang_f_opts.c
@@ -277,7 +277,6 @@
 // RUN:     -fgcse-las                                                        \
 // RUN:     -fgcse-sm                                                         \
 // RUN:     -fipa-cp                                                          \
-// RUN:     -finline-functions-called-once                                    \
 // RUN:     -fmodulo-sched                                                    \
 // RUN:     -fmodulo-sched-allow-regmoves                                     \
 // RUN:     -fpeel-loops                                                      \
@@ -349,7 +348,6 @@
 // RUN: -fgcse-las                                                            \
 // RUN: -fgcse-sm                                                             \
 // RUN: -fipa-cp                                                              \
-// RUN: -finline-functions-called-once                                        \
 // RUN: -fmodulo-sched                                                        \
 // RUN: -fmodulo-sched-allow-regmoves                                         \
 // RUN: -fpeel-loops                                                          \
@@ -409,7 +407,6 @@
 // CHECK-WARNING-DAG: optimization flag '-fgcse-las' is not supported
 // CHECK-WARNING-DAG: optimization flag '-fgcse-sm' is not supported
 // CHECK-WARNING-DAG: optimization flag '-fipa-cp' is not supported
-// CHECK-WARNING-DAG: optimization flag '-finline-functions-called-once' is not supported
 // CHECK-WARNING-DAG: optimization flag '-fmodulo-sched' is not supported
 // CHECK-WARNING-DAG: optimization flag '-fmodulo-sched-allow-regmoves' is not supported
 // CHECK-WARNING-DAG: optimization flag '-fpeel-loops' is not supported
diff --git a/clang/test/Driver/fno-inline-functions-called-once.c b/clang/test/Driver/fno-inline-functions-called-once.c
new file mode 100644
index 0000000000000..d866f9f83358b
--- /dev/null
+++ b/clang/test/Driver/fno-inline-functions-called-once.c
@@ -0,0 +1,20 @@
+// REQUIRES: x86-registered-target
+
+// Check that -fno-inline-functions-called-once is forwarded to LLVM.
+// RUN: %clang -### -S %s -fno-inline-functions-called-once 2>&1 \
+// RUN:   | FileCheck %s --check-prefix=FWD
+// FWD: "-mllvm" "-no-inline-functions-called-once"
+
+// Check that the positive form does NOT forward anything to -mllvm.
+// RUN: %clang -### -S %s -finline-functions-called-once 2>&1 \
+// RUN:   | FileCheck %s --check-prefix=POS
+// POS-NOT: -mllvm
+// POS-NOT: -no-inline-functions-called-once
+
+// Help text should show both flags (order-independent).
+// RUN: %clang --help 2>&1 | FileCheck %s --check-prefix=HELP
+// HELP-DAG: -finline-functions-called-once
+// HELP-DAG: -fno-inline-functions-called-once
+
+int x;
+
diff --git a/llvm/include/llvm/Transforms/IPO/NoInlineFuncCalledOnce.h b/llvm/include/llvm/Transforms/IPO/NoInlineFuncCalledOnce.h
new file mode 100644
index 0000000000000..41f782c02cd8c
--- /dev/null
+++ b/llvm/include/llvm/Transforms/IPO/NoInlineFuncCalledOnce.h
@@ -0,0 +1,18 @@
+#ifndef LLVM_TRANSFORMS_IPO_NOINLINEFUNCCALLEDONCE_H
+#define LLVM_TRANSFORMS_IPO_NOINLINEFUNCCALLEDONCE_H
+
+#include "llvm/IR/PassManager.h"
+#include "llvm/Support/CommandLine.h"
+
+namespace llvm {
+
+struct NoInlineFuncCalledOncePass
+    : public PassInfoMixin<NoInlineFuncCalledOncePass> {
+  PreservedAnalyses run(Module &M, ModuleAnalysisManager &);
+};
+
+// single definition of the control flag lives in the .cpp (declared here)
+extern cl::opt<bool> EnableNoInlineFuncCalledOnce;
+
+} // namespace llvm
+#endif
diff --git a/llvm/lib/Passes/PassBuilderPipelines.cpp b/llvm/lib/Passes/PassBuilderPipelines.cpp
index 30c6f06be139d..fe8dc6d810eda 100644
--- a/llvm/lib/Passes/PassBuilderPipelines.cpp
+++ b/llvm/lib/Passes/PassBuilderPipelines.cpp
@@ -66,6 +66,7 @@
 #include "llvm/Transforms/IPO/MemProfContextDisambiguation.h"
 #include "llvm/Transforms/IPO/MergeFunctions.h"
 #include "llvm/Transforms/IPO/ModuleInliner.h"
+#include "llvm/Transforms/IPO/NoInlineFuncCalledOnce.h"
 #include "llvm/Transforms/IPO/OpenMPOpt.h"
 #include "llvm/Transforms/IPO/PartialInlining.h"
 #include "llvm/Transforms/IPO/SCCP.h"
@@ -150,6 +151,12 @@
 
 using namespace llvm;
 
+namespace llvm {
+cl::opt<bool> EnableNoInlineFuncCalledOnce(
+    "no-inline-functions-called-once", cl::init(false), cl::Hidden,
+    cl::desc("Mark TU-local functions called exactly once as noinline"));
+} // namespace llvm
+
 static cl::opt<InliningAdvisorMode> UseInlineAdvisor(
     "enable-ml-inliner", cl::init(InliningAdvisorMode::Default), cl::Hidden,
     cl::desc("Enable ML policy for inliner. Currently trained for -Oz only"),
@@ -1274,6 +1281,9 @@ PassBuilder::buildModuleSimplificationPipeline(OptimizationLevel Level,
                  PGOOpt->Action == PGOOptions::SampleUse))
     MPM.addPass(PGOForceFunctionAttrsPass(PGOOpt->ColdOptType));
 
+  if (EnableNoInlineFuncCalledOnce)
+    MPM.addPass(NoInlineFuncCalledOncePass());
+
   MPM.addPass(AlwaysInlinerPass(/*InsertLifetimeIntrinsics=*/true));
 
   if (EnableModuleInliner)
@@ -1447,6 +1457,9 @@ PassBuilder::buildModuleOptimizationPipeline(OptimizationLevel Level,
   const bool LTOPreLink = isLTOPreLink(LTOPhase);
   ModulePassManager MPM;
 
+  if (EnableNoInlineFuncCalledOnce)
+    MPM.addPass(NoInlineFuncCalledOncePass());
+
   // Run partial inlining pass to partially inline functions that have
   // large bodies.
   if (RunPartialInlining)
@@ -1766,6 +1779,9 @@ PassBuilder::buildThinLTOPreLinkDefaultPipeline(OptimizationLevel Level) {
     return MPM;
   }
 
+  if (EnableNoInlineFuncCalledOnce)
+    MPM.addPass(NoInlineFuncCalledOncePass());
+
   // Run partial inlining pass to partially inline functions that have
   // large bodies.
   // FIXME: It isn't clear whether this is really the right place to run this
@@ -2012,6 +2028,9 @@ PassBuilder::buildLTODefaultPipeline(OptimizationLevel Level,
   // Lower variadic functions for supported targets prior to inlining.
   MPM.addPass(ExpandVariadicsPass(ExpandVariadicsMode::Optimize));
 
+  if (EnableNoInlineFuncCalledOnce)
+    MPM.addPass(NoInlineFuncCalledOncePass());
+
   // Note: historically, the PruneEH pass was run first to deduce nounwind and
   // generally clean up exception handling overhead. It isn't clear this is
   // valuable as the inliner doesn't currently care whether it is inlining an
diff --git a/llvm/lib/Transforms/IPO/CMakeLists.txt b/llvm/lib/Transforms/IPO/CMakeLists.txt
index 1c4ee0336d4db..50290513482ca 100644
--- a/llvm/lib/Transforms/IPO/CMakeLists.txt
+++ b/llvm/lib/Transforms/IPO/CMakeLists.txt
@@ -33,6 +33,7 @@ add_llvm_component_library(LLVMipo
   MemProfContextDisambiguation.cpp
   MergeFunctions.cpp
   ModuleInliner.cpp
+  NoInlineFuncCalledOnce.cpp
   OpenMPOpt.cpp
   PartialInlining.cpp
   SampleContextTracker.cpp
diff --git a/llvm/lib/Transforms/IPO/NoInlineFuncCalledOnce.cpp b/llvm/lib/Transforms/IPO/NoInlineFuncCalledOnce.cpp
new file mode 100644
index 0000000000000..44b56c3549791
--- /dev/null
+++ b/llvm/lib/Transforms/IPO/NoInlineFuncCalledOnce.cpp
@@ -0,0 +1,62 @@
+#include "llvm/Transforms/IPO/NoInlineFuncCalledOnce.h"
+#include "llvm/ADT/DenseMap.h"
+#include "llvm/ADT/DenseSet.h"
+#include "llvm/IR/Attributes.h"
+#include "llvm/IR/Function.h"
+#include "llvm/IR/InstIterator.h"
+#include "llvm/IR/Instructions.h"
+#include "llvm/IR/Module.h"
+#include "llvm/IR/PassManager.h"
+#include "llvm/Support/CommandLine.h"
+
+using namespace llvm;
+
+PreservedAnalyses NoInlineFuncCalledOncePass::run(Module &M,
+                                                  ModuleAnalysisManager &) {
+  DenseMap<Function *, unsigned> DirectCalls;
+  DenseSet<Function *> Recursive;
+
+  for (Function &F : M)
+    if (!F.isDeclaration() && (F.hasInternalLinkage() || F.hasPrivateLinkage()))
+      DirectCalls[&F] = 0;
+
+  for (Function &Caller : M) {
+    if (Caller.isDeclaration())
+      continue;
+    for (Instruction &I : instructions(Caller)) {
+      auto *CB = dyn_cast<CallBase>(&I);
+      if (!CB)
+        continue;
+      const Value *Op = CB->getCalledOperand()->stripPointerCasts();
+      if (auto *Callee = const_cast<Function *>(dyn_cast<Function>(Op))) {
+        if (!DirectCalls.count(Callee))
+          continue;
+        DirectCalls[Callee] += 1;
+        if (&Caller == Callee)
+          Recursive.insert(Callee);
+      }
+    }
+  }
+
+  bool Changed = false;
+  for (auto &KV : DirectCalls) {
+    Function *F = KV.first;
+    unsigned N = KV.second;
+
+    if (N != 1)
+      continue; // only called-once
+    if (Recursive.count(F))
+      continue; // skip recursion
+    if (F->hasAddressTaken())
+      continue; // skip address-taken
+    if (F->hasFnAttribute(Attribute::AlwaysInline))
+      continue;
+    if (F->hasFnAttribute(Attribute::NoInline))
+      continue;
+
+    F->addFnAttr(Attribute::NoInline);
+    Changed = true;
+  }
+
+  return Changed ? PreservedAnalyses::none() : PreservedAnalyses::all();
+}

@llvmbot
Copy link
Member

llvmbot commented Sep 23, 2025

@llvm/pr-subscribers-clang

Author: kd0608 (Karthikdhondi)

Changes

…ositive form in --help

This wires the Clang driver boolean flag and forwards the negative form to LLVM as '-mllvm -no-inline-functions-called-once'. The positive form is exposed in --help (no backend flag forwarded). Default behavior is unchanged unless the flag is passed.

LLVM side: add the NoInlineFuncCalledOnce pass and thread the flag through PassBuilder pipelines.

Tests:

  • clang/test/Driver/fno_inline_functions_called_once.c
  • clang/test/CodeGen/no_inline_called_once.c

Also drops obsolete 'unsupported flag' expectations in clang/test/Driver/clang_f_opts.c.


Full diff: https://github.com/llvm/llvm-project/pull/160343.diff

9 Files Affected:

  • (modified) clang/include/clang/Driver/Options.td (+6-2)
  • (modified) clang/lib/Driver/ToolChains/Clang.cpp (+6)
  • (added) clang/test/CodeGen/no-inline-func-called-once.c (+32)
  • (modified) clang/test/Driver/clang_f_opts.c (-3)
  • (added) clang/test/Driver/fno-inline-functions-called-once.c (+20)
  • (added) llvm/include/llvm/Transforms/IPO/NoInlineFuncCalledOnce.h (+18)
  • (modified) llvm/lib/Passes/PassBuilderPipelines.cpp (+19)
  • (modified) llvm/lib/Transforms/IPO/CMakeLists.txt (+1)
  • (added) llvm/lib/Transforms/IPO/NoInlineFuncCalledOnce.cpp (+62)
diff --git a/clang/include/clang/Driver/Options.td b/clang/include/clang/Driver/Options.td
index 16e1c396fedbe..6c6d5f8455d4f 100644
--- a/clang/include/clang/Driver/Options.td
+++ b/clang/include/clang/Driver/Options.td
@@ -6959,8 +6959,12 @@ defm merge_constants : BooleanFFlag<"merge-constants">, Group<clang_ignored_gcc_
 defm modulo_sched : BooleanFFlag<"modulo-sched">, Group<clang_ignored_gcc_optimization_f_Group>;
 defm modulo_sched_allow_regmoves : BooleanFFlag<"modulo-sched-allow-regmoves">,
     Group<clang_ignored_gcc_optimization_f_Group>;
-defm inline_functions_called_once : BooleanFFlag<"inline-functions-called-once">,
-    Group<clang_ignored_gcc_optimization_f_Group>;
+defm inline_functions_called_once
+  : BooleanFFlag<"inline-functions-called-once">,
+    Group<f_clang_Group>,
+    Visibility<[ClangOption, CC1Option]>,
+    HelpText<"Control inlining of TU-local functions called exactly once "
+             "(use -fno-inline-functions-called-once to inhibit it)">;
 def finline_limit_EQ : Joined<["-"], "finline-limit=">, Group<clang_ignored_gcc_optimization_f_Group>;
 defm finline_limit : BooleanFFlag<"inline-limit">, Group<clang_ignored_gcc_optimization_f_Group>;
 defm inline_small_functions : BooleanFFlag<"inline-small-functions">,
diff --git a/clang/lib/Driver/ToolChains/Clang.cpp b/clang/lib/Driver/ToolChains/Clang.cpp
index f67454ee517bd..78f2f209b7165 100644
--- a/clang/lib/Driver/ToolChains/Clang.cpp
+++ b/clang/lib/Driver/ToolChains/Clang.cpp
@@ -7266,6 +7266,12 @@ void Clang::ConstructJob(Compilation &C, const JobAction &JA,
 
   Args.AddLastArg(CmdArgs, options::OPT_finline_max_stacksize_EQ);
 
+  // Forward -fno-inline-functions-called-once to LLVM so the pass is enabled.
+  if (Args.hasArg(options::OPT_fno_inline_functions_called_once)) {
+    CmdArgs.push_back("-mllvm");
+    CmdArgs.push_back("-no-inline-functions-called-once");
+  }
+
   // FIXME: Find a better way to determine whether we are in C++20.
   bool HaveCxx20 =
       Std &&
diff --git a/clang/test/CodeGen/no-inline-func-called-once.c b/clang/test/CodeGen/no-inline-func-called-once.c
new file mode 100644
index 0000000000000..92cdb57297739
--- /dev/null
+++ b/clang/test/CodeGen/no-inline-func-called-once.c
@@ -0,0 +1,32 @@
+// REQUIRES: x86-registered-target
+// RUN: %clang -O1 -S -emit-llvm %s -fno-inline-functions-called-once -o - | FileCheck %s --check-prefix=NOINLINE
+
+// We verify three things:
+//   1) There is a surviving call to bad_function (so it wasn’t inlined).
+//   2) bad_function’s definition exists and carries an attribute group id.
+//   3) That attribute group includes 'noinline'.
+
+// The call is earlier in the IR than the callee/attributes, so use -DAG for the
+// first two checks to avoid order constraints, then pin the attributes match.
+
+// NOINLINE-DAG: call{{.*}} @bad_function{{.*}}
+// NOINLINE-DAG: define internal{{.*}} @bad_function{{.*}} #[[ATTR:[0-9]+]]
+// NOINLINE: attributes #[[ATTR]] = { {{.*}}noinline{{.*}} }
+
+volatile int G;
+
+static void bad_function(void) {
+  // Volatile side effect ensures the call can’t be DCE’d.
+  G++;
+}
+
+static void test(void) {
+  // Exactly one TU-local caller of bad_function.
+  bad_function();
+}
+
+int main(void) {
+  // Make the caller reachable so it survives global DCE.
+  test();
+  return 0;
+}
diff --git a/clang/test/Driver/clang_f_opts.c b/clang/test/Driver/clang_f_opts.c
index bdeb747aa66a3..39726ac78140c 100644
--- a/clang/test/Driver/clang_f_opts.c
+++ b/clang/test/Driver/clang_f_opts.c
@@ -277,7 +277,6 @@
 // RUN:     -fgcse-las                                                        \
 // RUN:     -fgcse-sm                                                         \
 // RUN:     -fipa-cp                                                          \
-// RUN:     -finline-functions-called-once                                    \
 // RUN:     -fmodulo-sched                                                    \
 // RUN:     -fmodulo-sched-allow-regmoves                                     \
 // RUN:     -fpeel-loops                                                      \
@@ -349,7 +348,6 @@
 // RUN: -fgcse-las                                                            \
 // RUN: -fgcse-sm                                                             \
 // RUN: -fipa-cp                                                              \
-// RUN: -finline-functions-called-once                                        \
 // RUN: -fmodulo-sched                                                        \
 // RUN: -fmodulo-sched-allow-regmoves                                         \
 // RUN: -fpeel-loops                                                          \
@@ -409,7 +407,6 @@
 // CHECK-WARNING-DAG: optimization flag '-fgcse-las' is not supported
 // CHECK-WARNING-DAG: optimization flag '-fgcse-sm' is not supported
 // CHECK-WARNING-DAG: optimization flag '-fipa-cp' is not supported
-// CHECK-WARNING-DAG: optimization flag '-finline-functions-called-once' is not supported
 // CHECK-WARNING-DAG: optimization flag '-fmodulo-sched' is not supported
 // CHECK-WARNING-DAG: optimization flag '-fmodulo-sched-allow-regmoves' is not supported
 // CHECK-WARNING-DAG: optimization flag '-fpeel-loops' is not supported
diff --git a/clang/test/Driver/fno-inline-functions-called-once.c b/clang/test/Driver/fno-inline-functions-called-once.c
new file mode 100644
index 0000000000000..d866f9f83358b
--- /dev/null
+++ b/clang/test/Driver/fno-inline-functions-called-once.c
@@ -0,0 +1,20 @@
+// REQUIRES: x86-registered-target
+
+// Check that -fno-inline-functions-called-once is forwarded to LLVM.
+// RUN: %clang -### -S %s -fno-inline-functions-called-once 2>&1 \
+// RUN:   | FileCheck %s --check-prefix=FWD
+// FWD: "-mllvm" "-no-inline-functions-called-once"
+
+// Check that the positive form does NOT forward anything to -mllvm.
+// RUN: %clang -### -S %s -finline-functions-called-once 2>&1 \
+// RUN:   | FileCheck %s --check-prefix=POS
+// POS-NOT: -mllvm
+// POS-NOT: -no-inline-functions-called-once
+
+// Help text should show both flags (order-independent).
+// RUN: %clang --help 2>&1 | FileCheck %s --check-prefix=HELP
+// HELP-DAG: -finline-functions-called-once
+// HELP-DAG: -fno-inline-functions-called-once
+
+int x;
+
diff --git a/llvm/include/llvm/Transforms/IPO/NoInlineFuncCalledOnce.h b/llvm/include/llvm/Transforms/IPO/NoInlineFuncCalledOnce.h
new file mode 100644
index 0000000000000..41f782c02cd8c
--- /dev/null
+++ b/llvm/include/llvm/Transforms/IPO/NoInlineFuncCalledOnce.h
@@ -0,0 +1,18 @@
+#ifndef LLVM_TRANSFORMS_IPO_NOINLINEFUNCCALLEDONCE_H
+#define LLVM_TRANSFORMS_IPO_NOINLINEFUNCCALLEDONCE_H
+
+#include "llvm/IR/PassManager.h"
+#include "llvm/Support/CommandLine.h"
+
+namespace llvm {
+
+struct NoInlineFuncCalledOncePass
+    : public PassInfoMixin<NoInlineFuncCalledOncePass> {
+  PreservedAnalyses run(Module &M, ModuleAnalysisManager &);
+};
+
+// single definition of the control flag lives in the .cpp (declared here)
+extern cl::opt<bool> EnableNoInlineFuncCalledOnce;
+
+} // namespace llvm
+#endif
diff --git a/llvm/lib/Passes/PassBuilderPipelines.cpp b/llvm/lib/Passes/PassBuilderPipelines.cpp
index 30c6f06be139d..fe8dc6d810eda 100644
--- a/llvm/lib/Passes/PassBuilderPipelines.cpp
+++ b/llvm/lib/Passes/PassBuilderPipelines.cpp
@@ -66,6 +66,7 @@
 #include "llvm/Transforms/IPO/MemProfContextDisambiguation.h"
 #include "llvm/Transforms/IPO/MergeFunctions.h"
 #include "llvm/Transforms/IPO/ModuleInliner.h"
+#include "llvm/Transforms/IPO/NoInlineFuncCalledOnce.h"
 #include "llvm/Transforms/IPO/OpenMPOpt.h"
 #include "llvm/Transforms/IPO/PartialInlining.h"
 #include "llvm/Transforms/IPO/SCCP.h"
@@ -150,6 +151,12 @@
 
 using namespace llvm;
 
+namespace llvm {
+cl::opt<bool> EnableNoInlineFuncCalledOnce(
+    "no-inline-functions-called-once", cl::init(false), cl::Hidden,
+    cl::desc("Mark TU-local functions called exactly once as noinline"));
+} // namespace llvm
+
 static cl::opt<InliningAdvisorMode> UseInlineAdvisor(
     "enable-ml-inliner", cl::init(InliningAdvisorMode::Default), cl::Hidden,
     cl::desc("Enable ML policy for inliner. Currently trained for -Oz only"),
@@ -1274,6 +1281,9 @@ PassBuilder::buildModuleSimplificationPipeline(OptimizationLevel Level,
                  PGOOpt->Action == PGOOptions::SampleUse))
     MPM.addPass(PGOForceFunctionAttrsPass(PGOOpt->ColdOptType));
 
+  if (EnableNoInlineFuncCalledOnce)
+    MPM.addPass(NoInlineFuncCalledOncePass());
+
   MPM.addPass(AlwaysInlinerPass(/*InsertLifetimeIntrinsics=*/true));
 
   if (EnableModuleInliner)
@@ -1447,6 +1457,9 @@ PassBuilder::buildModuleOptimizationPipeline(OptimizationLevel Level,
   const bool LTOPreLink = isLTOPreLink(LTOPhase);
   ModulePassManager MPM;
 
+  if (EnableNoInlineFuncCalledOnce)
+    MPM.addPass(NoInlineFuncCalledOncePass());
+
   // Run partial inlining pass to partially inline functions that have
   // large bodies.
   if (RunPartialInlining)
@@ -1766,6 +1779,9 @@ PassBuilder::buildThinLTOPreLinkDefaultPipeline(OptimizationLevel Level) {
     return MPM;
   }
 
+  if (EnableNoInlineFuncCalledOnce)
+    MPM.addPass(NoInlineFuncCalledOncePass());
+
   // Run partial inlining pass to partially inline functions that have
   // large bodies.
   // FIXME: It isn't clear whether this is really the right place to run this
@@ -2012,6 +2028,9 @@ PassBuilder::buildLTODefaultPipeline(OptimizationLevel Level,
   // Lower variadic functions for supported targets prior to inlining.
   MPM.addPass(ExpandVariadicsPass(ExpandVariadicsMode::Optimize));
 
+  if (EnableNoInlineFuncCalledOnce)
+    MPM.addPass(NoInlineFuncCalledOncePass());
+
   // Note: historically, the PruneEH pass was run first to deduce nounwind and
   // generally clean up exception handling overhead. It isn't clear this is
   // valuable as the inliner doesn't currently care whether it is inlining an
diff --git a/llvm/lib/Transforms/IPO/CMakeLists.txt b/llvm/lib/Transforms/IPO/CMakeLists.txt
index 1c4ee0336d4db..50290513482ca 100644
--- a/llvm/lib/Transforms/IPO/CMakeLists.txt
+++ b/llvm/lib/Transforms/IPO/CMakeLists.txt
@@ -33,6 +33,7 @@ add_llvm_component_library(LLVMipo
   MemProfContextDisambiguation.cpp
   MergeFunctions.cpp
   ModuleInliner.cpp
+  NoInlineFuncCalledOnce.cpp
   OpenMPOpt.cpp
   PartialInlining.cpp
   SampleContextTracker.cpp
diff --git a/llvm/lib/Transforms/IPO/NoInlineFuncCalledOnce.cpp b/llvm/lib/Transforms/IPO/NoInlineFuncCalledOnce.cpp
new file mode 100644
index 0000000000000..44b56c3549791
--- /dev/null
+++ b/llvm/lib/Transforms/IPO/NoInlineFuncCalledOnce.cpp
@@ -0,0 +1,62 @@
+#include "llvm/Transforms/IPO/NoInlineFuncCalledOnce.h"
+#include "llvm/ADT/DenseMap.h"
+#include "llvm/ADT/DenseSet.h"
+#include "llvm/IR/Attributes.h"
+#include "llvm/IR/Function.h"
+#include "llvm/IR/InstIterator.h"
+#include "llvm/IR/Instructions.h"
+#include "llvm/IR/Module.h"
+#include "llvm/IR/PassManager.h"
+#include "llvm/Support/CommandLine.h"
+
+using namespace llvm;
+
+PreservedAnalyses NoInlineFuncCalledOncePass::run(Module &M,
+                                                  ModuleAnalysisManager &) {
+  DenseMap<Function *, unsigned> DirectCalls;
+  DenseSet<Function *> Recursive;
+
+  for (Function &F : M)
+    if (!F.isDeclaration() && (F.hasInternalLinkage() || F.hasPrivateLinkage()))
+      DirectCalls[&F] = 0;
+
+  for (Function &Caller : M) {
+    if (Caller.isDeclaration())
+      continue;
+    for (Instruction &I : instructions(Caller)) {
+      auto *CB = dyn_cast<CallBase>(&I);
+      if (!CB)
+        continue;
+      const Value *Op = CB->getCalledOperand()->stripPointerCasts();
+      if (auto *Callee = const_cast<Function *>(dyn_cast<Function>(Op))) {
+        if (!DirectCalls.count(Callee))
+          continue;
+        DirectCalls[Callee] += 1;
+        if (&Caller == Callee)
+          Recursive.insert(Callee);
+      }
+    }
+  }
+
+  bool Changed = false;
+  for (auto &KV : DirectCalls) {
+    Function *F = KV.first;
+    unsigned N = KV.second;
+
+    if (N != 1)
+      continue; // only called-once
+    if (Recursive.count(F))
+      continue; // skip recursion
+    if (F->hasAddressTaken())
+      continue; // skip address-taken
+    if (F->hasFnAttribute(Attribute::AlwaysInline))
+      continue;
+    if (F->hasFnAttribute(Attribute::NoInline))
+      continue;
+
+    F->addFnAttr(Attribute::NoInline);
+    Changed = true;
+  }
+
+  return Changed ? PreservedAnalyses::none() : PreservedAnalyses::all();
+}

Copy link
Contributor

@boomanaiden154 boomanaiden154 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is the use case for this?

Copy link
Contributor

@jhuber6 jhuber6 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The LLVM changes and Clang flag changes should likely be separate

@jhuber6 jhuber6 requested a review from MaskRay September 23, 2025 16:47
@aeubanks
Copy link
Contributor

What is the use case for this?

+1, please add motivation in the description

@Karthikdhondi Karthikdhondi force-pushed the no-inline-functions-called-once branch from caa85aa to eebc35e Compare September 24, 2025 09:12
@Karthikdhondi
Copy link
Contributor Author

The LLVM changes and Clang flag changes should likely be separate

Thanks! I’ve split the change as suggested.

@Karthikdhondi
Copy link
Contributor Author

What is the use case for this?

Thanks, I’ve added the motivation in the description.

@Karthikdhondi
Copy link
Contributor Author

What is the use case for this?

+1, please add motivation in the description

Thanks, I’ve added the motivation in the description.

@MaskRay
Copy link
Member

MaskRay commented Sep 28, 2025

While GCC has -finline-functions-called-once, we don't blindly copy every option it supports. Please provide clear, technical justification. The description looks AI-generated, making it difficult to assess its value.

Thanks! I’ve split the change as suggested.

I don't think you split the LLVM and Clang side changes.

@Karthikdhondi
Copy link
Contributor Author

While GCC has -finline-functions-called-once, we don't blindly copy every option it supports. Please provide clear, technical justification. The description looks AI-generated, making it difficult to assess its value.

Thanks! I’ve split the change as suggested.

I don't think you split the LLVM and Clang side changes.

Apologies for not conveying better.

We are working on a large application code base that was earlier compiled with GNU toolchain. Compiler option "-fno-inline-functions-called-once" was used. Now considering the benefits of LLVM toolchain, they want to have similar option with LLVM. Using attribute((no-inline)), requires making changes to application which is not a feasible option.

So, I have enabled the pass in LLVM it worked fine, and thought to create a PR for the same. So that, it might be helpful for others too.

By splitting the changes means, Do I need to create two different PRs for LLVM and Clang side changes?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

clang:driver 'clang' and 'clang++' user-facing binaries. Not 'clang-cl' clang Clang issues not falling into any other category llvm:transforms

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants