Tensor Flow 📂 Graphs and Speed with tf.function · 5 of 5 28 min read

Speed Test: Eager vs Graph Mode Benchmark in TensorFlow

Measure the real speed-up of tf.function. Learn to benchmark fairly with warm-up, timeit and .numpy(). Compare eager and graph mode on many small ops, one big matmul, a full training step and the first-call tracing cost, plus XLA with jit_compile. Race both modes in a simulator tuned to real measurements.

Section 01

The Story — A Fair Race

Don't Time the Runner Tying His Shoes
Two runners race. One is fast but must tie his shoes before the start. If you start the stopwatch while he is still tying them, he looks slow. If you stop the watch before he crosses the line, he looks fast. Neither result is true.

Timing code is the same. A tf.function "ties its shoes" on the first call — that is tracing. A GPU can return control before it has finished the work. To get a fair result, you must warm up first and wait for the finish line.

Everyone says "graphs are faster". In this lesson you will measure it yourself, and find out when it is true — and when it is not. All numbers below were measured on our small 2-core CPU test machine. Your numbers will be different, but the pattern will be the same.


Section 02

How to Time TensorFlow Code Fairly

01
Warm up
Call the function once before timing. This does the tracing, so it is not counted in the speed test.
02
Repeat many times
One call takes microseconds. Time 100 calls, and repeat that 3 times.
03
Wait for the result
On a GPU, ops run in the background. Call .numpy() on the output so the clock stops only when the work is done.
04
Take the best run
Other programs can slow one run down. The fastest of the repeats is the most honest number.
import tensorflow as tf
import timeit

def bench(fn, *args, number=100):
    fn(*args)                                              # 1. warm-up (traces if needed)
    run = lambda: fn(*args).numpy()                        # 3. wait for the result
    best = min(timeit.repeat(run, number=number, repeat=3))   # 2 + 4
    return best / number * 1e6                             # microseconds per call

print("helper ready")
OUTPUT
helper ready

Section 03

Test 1 — Many Small Ops

A loop of 50 steps, 3 tiny ops each, on a 10 × 10 tensor. This is where eager mode suffers most: 150 trips between Python and the runtime.

def small_ops(x):
    for _ in range(50):
        x = tf.nn.relu(x * 0.99 + 0.01)
    return x

x = tf.random.normal([10, 10])
graph_small = tf.function(small_ops)

eager_us = bench(small_ops, x)
graph_us = bench(graph_small, x)
print(f"eager : {eager_us:8.1f} µs per call")
print(f"graph : {graph_us:8.1f} µs per call")
print(f"speed-up: {eager_us / graph_us:.1f}x")
OUTPUT — our 2-core CPU test machine
eager : 2823.6 µs per call graph : 207.7 µs per call speed-up: 13.6x

Section 04

Test 2 — One Big Op

Now the opposite: a single 1000 × 1000 matrix multiply. The maths itself takes most of the time, so there is little Python overhead to remove.

def big_matmul(a):
    return tf.matmul(a, a)

a = tf.random.normal([1000, 1000])
graph_big = tf.function(big_matmul)

eager_us = bench(big_matmul, a, number=10)
graph_us = bench(graph_big, a, number=10)
print(f"eager : {eager_us / 1000:6.2f} ms per call")
print(f"graph : {graph_us / 1000:6.2f} ms per call")
print(f"speed-up: {eager_us / graph_us:.2f}x")
OUTPUT — our 2-core CPU test machine
eager : 6.02 ms per call graph : 6.43 ms per call speed-up: 0.94x
💡
Why Almost No Gain?

A graph removes overhead, not maths. One big matmul has one op, so there was only one Python round trip to remove. The same C++ kernel does the real work in both modes.


Section 05

Test 3 — A Real Training Step

This is the case that matters most. A training step has a forward pass, a loss, a backward pass and an optimizer update — dozens of small ops.

tf.random.set_seed(0)
model = tf.keras.Sequential([
    tf.keras.Input(shape=(20,)),
    tf.keras.layers.Dense(64, activation="relu"),
    tf.keras.layers.Dense(1),
])
opt = tf.keras.optimizers.SGD(0.01)
X, Y = tf.random.normal([64, 20]), tf.random.normal([64, 1])

def train_step(X, Y):
    with tf.GradientTape() as tape:
        loss = tf.reduce_mean((model(X) - Y) ** 2)
    grads = tape.gradient(loss, model.trainable_variables)
    opt.apply_gradients(zip(grads, model.trainable_variables))
    return loss

graph_step = tf.function(train_step)

eager_us = bench(train_step, X, Y, number=50)
graph_us = bench(graph_step, X, Y, number=50)
print(f"eager step : {eager_us / 1000:6.2f} ms")
print(f"graph step : {graph_us / 1000:6.2f} ms")
print(f"speed-up   : {eager_us / graph_us:.1f}x   (Keras fit() does this for you)")
OUTPUT — our 2-core CPU test machine
eager step : 3.81 ms graph step : 0.33 ms speed-up : 11.5x (Keras fit() does this for you)

Section 06

Test 4 — The Cost of Tracing

The first call is slow because it traces. That cost is paid once per signature — and again on every retrace.

import time

fresh = tf.function(train_step)
t0 = time.perf_counter(); fresh(X, Y).numpy(); first = time.perf_counter() - t0
t0 = time.perf_counter(); fresh(X, Y).numpy(); second = time.perf_counter() - t0

print(f"1st call (trace + run): {first * 1000:7.1f} ms")
print(f"2nd call (run only)   : {second * 1000:7.2f} ms")
print(f"tracing costs about {first / second:.0f} normal calls")
OUTPUT — our 2-core CPU test machine
1st call (trace + run): 40.7 ms 2nd call (run only) : 0.57 ms tracing costs about 72 normal calls
🔁
Retracing Can Wipe Out the Gain

If your function retraces on every call (Python numbers as arguments, changing shapes), you pay this big first-call cost every time. Then graph mode is slower than eager. Check experimental_get_tracing_count() during speed tests.

Bonus: XLA Compilation

jit_compile=True asks the XLA compiler to fuse ops into bigger kernels. It can help more, but test it — it does not always win.

xla_small = tf.function(small_ops, jit_compile=True)
print(f"graph     : {bench(graph_small, x):6.1f} µs")
print(f"graph+XLA : {bench(xla_small, x):6.1f} µs")
OUTPUT — our 2-core CPU test machine
graph : 202.9 µs graph+XLA : 125.8 µs

Section 07

Where the Time Goes

Animated Diagram — One Call, Two Timelines
eagergraph 0time → Python / dispatch overhead actual maths

Same maths (green) in both rows. Eager pays red overhead before every op; the graph pays it once.


Section 08

Try It — Speed Simulator

🏎️ Predict the Race Interactive

Describe your function: how many ops, how big the tensors are, and how many times you call it. Press Race. This is a simple model tuned to the real measurements above (eager ≈ 19 µs overhead per op; graph ≈ 60 µs per call + 1.1 µs per op; tracing ≈ 40 ms once).


Section 09

When Is tf.function Worth It?

SituationExpected gainWhy
Custom training loopLarge (often 5–15×)Many small ops per step, called thousands of times
Python loop over small opsLargeOverhead dominates the maths
Inference with a small modelMedium to largeCalled many times; export needs a graph anyway
One huge matmul or convolutionTinyMaths dominates; the kernel is the same
Code called only onceNegativeTracing costs more than it saves
Function that retraces every callNegativeYou pay the tracing cost again and again

Section 10

Golden Rules

🏎️ Speed Testing — Rules to Remember
1
Always warm up before timing. The first call includes tracing.
2
Call .numpy() on the result inside the timed code, so async GPU work is finished.
3
Repeat many calls and take the best run. Use timeit, not a single time.time().
4
Graphs remove overhead. Big gains for many small ops; little gain for a few huge ops.
5
Always wrap your custom training step in @tf.function. Check it does not retrace.
6
Try jit_compile=True as an extra step, and keep it only if your own benchmark says it is faster.
You have completed Graphs and Speed with tf.function. View all sections →