On Wed, Aug 19, 2026 at 2:34 PM Kyrylo Tkachov <[email protected]> wrote:
>
>
>
> > On 19 Aug 2026, at 14:06, Richard Biener <[email protected]> wrote:
> >
> > On Wed, Aug 19, 2026 at 1:03 PM <[email protected]> wrote:
> >>
> >> From: Kyrylo Tkachov <[email protected]>
> >>
> >> fwrite (p, 1, 1, f) transfers one byte and, when nothing looks at its
> >> result,
> >> writes exactly what fputc (*p, f) writes. fputc reaches the stream
> >> directly
> >> while fwrite goes through the generic buffered-write path, so the fold is
> >> worth making. GCC already treats the two as interchangeable in the other
> >> direction, since gimple_fold_builtin_fputs turns a one-character fputs into
> >> fputc and a longer one into fwrite. There was simply no folder for fwrite.
> >>
> >> The unlocked entry points fold the same way, into fputc_unlocked, on the
> >> assumption gimple_fold_builtin_fputs already makes: a program that calls
> >> one
> >> unlocked stdio function has the others available too.
> >>
> >> jq emits its output one character at a time through a helper that ends in
> >> fwrite (&c, 1, 1, f), which is the shape this fold is aimed at:
> >>
> >> before after
> >> stp x29, x30, [sp, -32]! and w0, w0, 255
> >> mov w2, 1 b fputc
> >> mov x29, sp
> >> mov x3, x1
> >> uxtw x1, w2
> >> strb w0, [sp, 31]
> >> add x0, sp, 31
> >> bl fwrite
> >> ldp x29, x30, [sp], 32
> >> ret
> >>
> >> Measured with jq 1.8.2 over a 57 MB NDJSON corpus on Grace with
> >> -mcpu=grace -O3 gets these speedups.
> >>
> >> jq -c . -30.30% instructions, -44.80% cycles
> >> jq -c -f <a map/select filter> -2.92% instructions, -5.67% cycles
> >>
> >> Bootstrapped and tested on aarch64-none-linux-gnu.
> >> Ok for trunk?
> >> Thanks,
> >> Kyrill
> >>
> >> gcc/ChangeLog:
> >>
> >> * gimple-fold.cc (gimple_fold_builtin_fwrite): New function.
> >> (gimple_fold_builtin): Call it for BUILT_IN_FWRITE and
> >> BUILT_IN_FWRITE_UNLOCKED.
> >>
> >> gcc/testsuite/ChangeLog:
> >>
> >> * gcc.dg/fwrite-1.c: New test.
> >> * gcc.dg/fwrite-2.c: New test.
> >> * gcc.dg/fwrite-3.c: New test.
> >>
> >> Signed-off-by: Kyrylo Tkachov <[email protected]>
> >> ---
> >> gcc/gimple-fold.cc | 47 +++++++++++++++++++++++++++++++++
> >> gcc/testsuite/gcc.dg/fwrite-1.c | 15 +++++++++++
> >> gcc/testsuite/gcc.dg/fwrite-2.c | 32 ++++++++++++++++++++++
> >> gcc/testsuite/gcc.dg/fwrite-3.c | 25 ++++++++++++++++++
> >> 4 files changed, 119 insertions(+)
> >> create mode 100644 gcc/testsuite/gcc.dg/fwrite-1.c
> >> create mode 100644 gcc/testsuite/gcc.dg/fwrite-2.c
> >> create mode 100644 gcc/testsuite/gcc.dg/fwrite-3.c
> >>
> >> diff --git a/gcc/gimple-fold.cc b/gcc/gimple-fold.cc
> >> index 24041466108..583afe119e7 100644
> >> --- a/gcc/gimple-fold.cc
> >> +++ b/gcc/gimple-fold.cc
> >> @@ -3099,6 +3099,49 @@ gimple_fold_builtin_fputs (gimple_stmt_iterator
> >> *gsi,
> >> }
> >> }
> >>
> >> +/* Fold a call to fwrite (PTR, SIZE, N, STREAM) at *GSI. UNLOCKED says
> >> whether
> >> + the callee is fwrite_unlocked rather than fwrite. A call that
> >> transfers a
> >> + single byte and whose result is nobody's business writes the same byte
> >> as
> >> + fputc (*PTR, STREAM), which reaches the stream without going through
> >> the
> >> + generic buffered-write path. Return true if the call was folded. */
> >> +
> >> +static bool
> >> +gimple_fold_builtin_fwrite (gimple_stmt_iterator *gsi, bool unlocked)
> >> +{
> >> + gimple *stmt = gsi_stmt (*gsi);
> >> +
> >> + /* fwrite reports the number of items transferred and fputc the
> >> character
> >> + written, so only fold when nothing looks at the result. */
> >> + if (gimple_call_lhs (stmt))
> >> + return false;
> >> +
> >> + /* fwrite transfers SIZE * N bytes, so writing a single byte needs both
> >> + counts to be one: no other pair of non-negative values multiplies to
> >> + one. */
> >> + if (!integer_onep (gimple_call_arg (stmt, 1))
> >> + || !integer_onep (gimple_call_arg (stmt, 2)))
> >> + return false;
> >> +
> >> + /* If we're using an unlocked function, assume the other unlocked
> >> + functions exist explicitly. */
> >> + tree const fn_fputc = (unlocked
> >> + ? builtin_decl_explicit (BUILT_IN_FPUTC_UNLOCKED)
> >> + : builtin_decl_implicit (BUILT_IN_FPUTC));
> >> + if (!fn_fputc || (!gimple_vdef (stmt) && gimple_in_ssa_p (cfun)))
> >> + return false;
> >> +
> >> + location_t loc = gimple_location (stmt);
> >> + gimple_seq stmts = NULL;
> >> + tree byte = gimple_load_first_char (loc, gimple_call_arg (stmt, 0),
> >> &stmts);
> >> + tree c = make_ssa_name (integer_type_node);
> >> + gimple_seq_add_stmt_without_update (&stmts,
> >> + gimple_build_assign (c, NOP_EXPR,
> >> byte));
> >
> > tree c = gimple_convert (&stmts, integer_type_node, byte);
>
> Will do, thanks.
>
> >
> >> + gimple_seq_add_stmt_without_update
> >> + (&stmts, gimple_build_call (fn_fputc, 2, c, gimple_call_arg (stmt,
> >> 3)));
> >
> > gimple_build (&stmts, fn_putc, void_type_node, c, gimple_call_arg (stmt,
> > 3));
> >
> > might work here (unsure about calls with no LHS).
>
> There's no gimple_build overload taking a function decl so that wouldn’t
> compile, unless I’m not looking in the right place.
Ah, true, it would be on BUILT_IN_FPUTC directly, no need to lookup
a decl here.
Richard.
> Thanks,
> Kyrill
>
> >
> >> + gsi_replace_with_seq_vops (gsi, stmts);
> >> + return true;
> >> +}
> >> +
> >> /* Fold a call to the __mem{cpy,pcpy,move,set}_chk builtin.
> >> DEST, SRC, LEN, and SIZE are the arguments to the call.
> >> IGNORE is true, if return value can be ignored. FCODE is the BUILT_IN_*
> >> @@ -5583,6 +5626,10 @@ gimple_fold_builtin (gimple_stmt_iterator *gsi)
> >> case BUILT_IN_FPUTS_UNLOCKED:
> >> return gimple_fold_builtin_fputs (gsi, gimple_call_arg (stmt, 0),
> >> gimple_call_arg (stmt, 1), true);
> >> + case BUILT_IN_FWRITE:
> >> + return gimple_fold_builtin_fwrite (gsi, false);
> >> + case BUILT_IN_FWRITE_UNLOCKED:
> >> + return gimple_fold_builtin_fwrite (gsi, true);
> >> case BUILT_IN_MEMCPY_CHK:
> >> case BUILT_IN_MEMPCPY_CHK:
> >> case BUILT_IN_MEMMOVE_CHK:
> >> diff --git a/gcc/testsuite/gcc.dg/fwrite-1.c
> >> b/gcc/testsuite/gcc.dg/fwrite-1.c
> >> new file mode 100644
> >> index 00000000000..5e3a5043e9a
> >> --- /dev/null
> >> +++ b/gcc/testsuite/gcc.dg/fwrite-1.c
> >> @@ -0,0 +1,15 @@
> >> +/* fwrite of a single byte whose result is unused writes the same byte as
> >> + fputc, so it should be folded. */
> >> +/* { dg-do compile } */
> >> +/* { dg-options "-O2 -Wno-unused-result -fdump-tree-optimized" } */
> >> +
> >> +#include <stdio.h>
> >> +
> >> +void one (FILE *f, const char *p) { fwrite (p, 1, 1, f); }
> >> +size_t used (FILE *f, const char *p) { return fwrite (p, 1, 1, f); }
> >> +void two_items (FILE *f, const char *p) { fwrite (p, 1, 2, f); }
> >> +void two_bytes (FILE *f, const char *p) { fwrite (p, 2, 1, f); }
> >> +void unknown (FILE *f, const char *p, size_t n) { fwrite (p, 1, n, f); }
> >> +
> >> +/* { dg-final { scan-tree-dump-times "fputc" 1 "optimized" } } */
> >> +/* { dg-final { scan-tree-dump-times "fwrite" 4 "optimized" } } */
> >> diff --git a/gcc/testsuite/gcc.dg/fwrite-2.c
> >> b/gcc/testsuite/gcc.dg/fwrite-2.c
> >> new file mode 100644
> >> index 00000000000..7565f52def8
> >> --- /dev/null
> >> +++ b/gcc/testsuite/gcc.dg/fwrite-2.c
> >> @@ -0,0 +1,32 @@
> >> +/* Check that folding fwrite of a single byte to fputc keeps the output
> >> the
> >> + same, including the bytes that are not folded, and that a side effect
> >> in the
> >> + stream argument is still evaluated exactly once. */
> >> +/* { dg-do run } */
> >> +/* { dg-options "-O2 -Wno-unused-result" } */
> >> +
> >> +#include <stdio.h>
> >> +
> >> +extern void abort (void);
> >> +
> >> +const char s[] = "abcd";
> >> +
> >> +int
> >> +main (void)
> >> +{
> >> + FILE *streams[] = { stdout, NULL }, **p = streams;
> >> +
> >> + fwrite (s, 1, 1, stdout);
> >> + fwrite (s + 1, 1, 1, stdout);
> >> + fwrite (s + 2, 1, 2, stdout);
> >> + fwrite (s, 2, 1, stdout);
> >> +
> >> + /* The folded call must still advance P exactly once. */
> >> + fwrite (s, 1, 1, *p++);
> >> + if (p != streams + 1 || *p != NULL)
> >> + abort ();
> >> +
> >> + fflush (stdout);
> >> + return 0;
> >> +}
> >> +
> >> +/* { dg-output "abcdaba" } */
> >> diff --git a/gcc/testsuite/gcc.dg/fwrite-3.c
> >> b/gcc/testsuite/gcc.dg/fwrite-3.c
> >> new file mode 100644
> >> index 00000000000..6ee0f58cb59
> >> --- /dev/null
> >> +++ b/gcc/testsuite/gcc.dg/fwrite-3.c
> >> @@ -0,0 +1,25 @@
> >> +/* fwrite_unlocked of a single byte whose result is unused writes the
> >> same byte
> >> + as fputc_unlocked, so it is folded the same way as the locked form,
> >> and into
> >> + the unlocked entry point rather than the locked one. */
> >> +/* { dg-do compile } */
> >> +/* { dg-options "-O2 -Wno-unused-result -fdump-tree-optimized" } */
> >> +
> >> +#include <stdio.h>
> >> +
> >> +/* Declared here rather than through _GNU_SOURCE so that the test does not
> >> + depend on the host header exposing the unlocked entry points. */
> >> +extern int (fputc_unlocked) (int, FILE *);
> >> +extern size_t (fwrite_unlocked) (const void *, size_t, size_t, FILE *);
> >> +
> >> +void one (FILE *f, const char *p) { fwrite_unlocked (p, 1, 1, f); }
> >> +size_t used (FILE *f, const char *p) { return fwrite_unlocked (p, 1, 1,
> >> f); }
> >> +void two_items (FILE *f, const char *p) { fwrite_unlocked (p, 1, 2, f); }
> >> +void two_bytes (FILE *f, const char *p) { fwrite_unlocked (p, 2, 1, f); }
> >> +void unknown (FILE *f, const char *p, size_t n) { fwrite_unlocked (p, 1,
> >> n, f); }
> >> +
> >> +/* { dg-final { scan-tree-dump-times "fputc_unlocked" 1 "optimized" } } */
> >> +/* { dg-final { scan-tree-dump-times "fwrite_unlocked" 4 "optimized" } }
> >> */
> >> +/* The unlocked form must not be folded into the locked fputc. A call
> >> with no
> >> + result is dumped as " fputc (...", which the leading space matches
> >> without
> >> + also matching fputc_unlocked. */
> >> +/* { dg-final { scan-tree-dump-not " fputc \\(" "optimized" } } */
> >> --
> >> 2.50.1 (Apple Git-155)
>
>