Skip to content

Commit ab69cda

Browse files
committed
fix: make previous_value byte-identical every iteration (airtight back-compat)
Set previous_value in the iterator's gated-out branch too, so reads of bar.previous_value mid-loop match the pre-gate every-iteration semantics exactly (not just at redraws). Closes the last residue of the backward- compatibility review concern. Costs ~7 ns/iter (now ~31 ns vs tqdm 56, still 2nd-fastest); README + benchmark updated to the honest figure. Adds a per-iteration previous_value assertion to the liveness test.
1 parent bc3716b commit ab69cda

6 files changed

Lines changed: 79 additions & 67 deletions

File tree

README.rst

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -77,25 +77,26 @@ Performance
7777
******************************************************************************
7878

7979
Wrapping a loop with ``progressbar2`` is cheap. On the benchmark machine
80-
(CPython 3.13, macOS arm64) it adds only about **24 nanoseconds per
81-
iteration** over a bare loop -- roughly **2.3x faster than tqdm** and within a
82-
few nanoseconds of ``rich``, while being far ahead of the rest:
80+
(CPython 3.13, macOS arm64) it adds only about **31 nanoseconds per
81+
iteration** over a bare loop -- roughly **1.8x faster than tqdm** and second
82+
only to ``rich``, while being far ahead of the rest:
8383

8484
================ ==================
8585
Library Overhead per iter
8686
================ ==================
8787
rich 19 ns
88-
progressbar2 24 ns
88+
progressbar2 31 ns
8989
tqdm 56 ns
90-
alive-progress 247 ns
91-
click 1878 ns
90+
alive-progress 262 ns
91+
click 1892 ns
9292
================ ==================
9393

9494
The per-iteration cost is dominated by deciding *whether* to redraw, not by
9595
drawing: ``progressbar2`` keeps an integer "next update" gate so the common
96-
iteration is just an increment and a compare, only entering the (rate-limited)
97-
redraw machinery a few times per second. Behaviour is unchanged -- the same
98-
widgets, the same redraw cadence.
96+
iteration is just an increment and a couple of cheap stores, only entering the
97+
(rate-limited) redraw machinery a few times per second. Behaviour is unchanged
98+
-- the same widgets, the same redraw cadence, and ``value``/``previous_value``
99+
stay byte-identical to the pre-gate implementation on every iteration.
99100

100101
The benchmark is fully reproducible and pits ``progressbar2`` against ``tqdm``,
101102
``rich``, ``alive-progress`` and ``click`` across iteration overhead, forced

benchmarks/chart.png

-2 KB
Loading

benchmarks/report.md

Lines changed: 18 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Python progress-bar library benchmark
22

3-
_Generated 2026-06-22 02:41. Subject: **progressbar2** (version 4.5.0)._
3+
_Generated 2026-06-22 12:37. Subject: **progressbar2** (version 4.5.0)._
44

55
Compares `progressbar2` against the most common alternatives across three independent dimensions. All rendered output is written to a real pseudo-terminal (pty) that is continuously drained, so every library believes it is attached to a TTY and actually draws — the comparison is apples-to-apples, not "is output suppressed when piped".
66

@@ -27,47 +27,47 @@ Compares `progressbar2` against the most common alternatives across three indepe
2727

2828
Idiomatic "wrap my loop" call with each library's **default** settings, over **1,000,000** iterations with a trivial body. This is the real-world cost of dropping a progress bar around a fast loop. Overhead = (wrapped time − bare-loop time) / iterations. Lower is faster.
2929

30-
Bare loop baseline: **5.52 ms** for 1,000,000 iterations.
30+
Bare loop baseline: **5.45 ms** for 1,000,000 iterations.
3131

3232
| Library | Total time | Overhead/iter | vs progressbar2 |
3333
|---|--:|--:|--:|
34-
| rich | 24.5 ms | 19.0 ns | 0.80x |
35-
| **progressbar2** | 29.3 ms | 23.8 ns | baseline |
36-
| tqdm | 61.5 ms | 55.9 ns | 2.35x |
37-
| alive-progress | 252.6 ms | 247.1 ns | 10.38x |
38-
| click | 1883.9 ms | 1878.3 ns | 78.90x |
34+
| rich | 24.4 ms | 18.9 ns | 0.62x |
35+
| **progressbar2** | 36.1 ms | 30.6 ns | baseline |
36+
| tqdm | 61.1 ms | 55.6 ns | 1.82x |
37+
| alive-progress | 267.6 ms | 262.1 ns | 8.57x |
38+
| click | 1897.1 ms | 1891.6 ns | 61.82x |
3939

4040
## B. Forced per-update render cost
4141

4242
Rendering **forced on every single update** over **30,000** updates — i.e. the cost of one full bar redraw, throttling disabled. Lower is faster.
4343

4444
| Library | Total time | Per rendered update | vs progressbar2 |
4545
|---|--:|--:|--:|
46-
| tqdm | 328.6 ms | 10.95 us | 0.39x |
47-
| **progressbar2** | 843.8 ms | 28.12 us | baseline |
48-
| rich | 5146.9 ms | 171.56 us | 6.10x |
46+
| tqdm | 349.0 ms | 11.63 us | 0.43x |
47+
| **progressbar2** | 809.1 ms | 26.96 us | baseline |
48+
| rich | 5103.9 ms | 170.13 us | 6.31x |
4949

5050
Excluded from this panel (no per-update force-render API):
5151
- **alive-progress** — renders on a background timer thread; no per-update render API
5252
- **click** — self-throttles writes (renders only when the drawn line changes); no force-every-update API
5353

5454
## C. Cold import time
5555

56-
Wall-clock cost of importing the library in a fresh interpreter (minimum of 9 runs), with bare-interpreter startup (18 ms) subtracted. Matters for short-lived CLIs. Lower is lighter.
56+
Wall-clock cost of importing the library in a fresh interpreter (minimum of 9 runs), with bare-interpreter startup (15 ms) subtracted. Matters for short-lived CLIs. Lower is lighter.
5757

5858
| Library | Import time (net) |
5959
|---|--:|
60-
| alive-progress | 11.0 ms |
61-
| tqdm | 25.6 ms |
62-
| click | 27.0 ms |
63-
| **progressbar2** | 49.8 ms |
64-
| rich | 53.0 ms |
60+
| alive-progress | 8.1 ms |
61+
| tqdm | 21.7 ms |
62+
| click | 23.4 ms |
63+
| **progressbar2** | 45.8 ms |
64+
| rich | 47.2 ms |
6565

6666
## Takeaways
6767

68-
- **Default per-iteration overhead:** `progressbar2` is 24 ns/iter, ranking #2 of 5. `rich` is the lightest per iteration (19 ns), `click` the heaviest (1878 ns).
68+
- **Default per-iteration overhead:** `progressbar2` is 31 ns/iter, ranking #2 of 5. `rich` is the lightest per iteration (19 ns), `click` the heaviest (1892 ns).
6969
- `rich` and `tqdm` win here because their default settings do almost no per-iteration work (counter compare / background refresh thread); `progressbar2` calls a monotonic clock and evaluates its redraw predicate on every `update()`.
70-
- **Render cost:** when a redraw actually happens, `progressbar2` draws one update in 28.1 us — 2.57x the cheapest (`tqdm`) but 6.1x cheaper than rich's full-display re-render.
70+
- **Render cost:** when a redraw actually happens, `progressbar2` draws one update in 27.0 us — 2.32x the cheapest (`tqdm`) but 6.3x cheaper than rich's full-display re-render.
7171
- **Why both numbers matter:** `progressbar2` caps redraws at ~20/sec by default (50 ms floor), so in practice the cheap render in B fires rarely and the per-iteration cost in A dominates real workloads.
7272
- **Import weight:** `progressbar2` is mid-pack to import; `alive-progress` is the lightest, `rich` the heaviest.
7373

benchmarks/results.json

Lines changed: 38 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -20,53 +20,53 @@
2020
"term": "80x24"
2121
},
2222
"scenario_a_default_overhead": {
23-
"baseline_min_s": 0.0056461249478161335,
24-
"baseline_median_s": 0.005713916849344969,
23+
"baseline_min_s": 0.005453874822705984,
24+
"baseline_median_s": 0.0054806252010166645,
2525
"libs": {
2626
"progressbar2": {
27-
"total_min_s": 0.04969320772215724,
28-
"total_median_s": 0.05024691578000784,
29-
"overhead_ns_per_iter": 44.047082774341106
27+
"total_min_s": 0.03605487523600459,
28+
"total_median_s": 0.03639816725626588,
29+
"overhead_ns_per_iter": 30.601000413298607
3030
},
3131
"tqdm": {
32-
"total_min_s": 0.06316162506118417,
33-
"total_median_s": 0.06452187523245811,
34-
"overhead_ns_per_iter": 57.515500113368034
32+
"total_min_s": 0.061058541759848595,
33+
"total_median_s": 0.06176149984821677,
34+
"overhead_ns_per_iter": 55.60466693714261
3535
},
3636
"rich": {
37-
"total_min_s": 0.025369917042553425,
38-
"total_median_s": 0.02551012486219406,
39-
"overhead_ns_per_iter": 19.72379209473729
37+
"total_min_s": 0.024352333042770624,
38+
"total_median_s": 0.024487290997058153,
39+
"overhead_ns_per_iter": 18.89845822006464
4040
},
4141
"alive-progress": {
42-
"total_min_s": 0.2668608748354018,
43-
"total_median_s": 0.2882573329843581,
44-
"overhead_ns_per_iter": 261.21474988758564
42+
"total_min_s": 0.2675985828973353,
43+
"total_median_s": 0.2798731252551079,
44+
"overhead_ns_per_iter": 262.1447080746293
4545
},
4646
"click": {
47-
"total_min_s": 1.9724276666529477,
48-
"total_median_s": 1.984468291979283,
49-
"overhead_ns_per_iter": 1966.7815417051315
47+
"total_min_s": 1.8970785411074758,
48+
"total_median_s": 1.9143713326193392,
49+
"overhead_ns_per_iter": 1891.6246662847698
5050
}
5151
}
5252
},
5353
"scenario_b_forced_render": {
54-
"baseline_min_s": 0.00016858289018273354,
54+
"baseline_min_s": 0.0001548328436911106,
5555
"libs": {
5656
"progressbar2": {
57-
"total_min_s": 0.7714061671867967,
58-
"total_median_s": 0.7764091249555349,
59-
"per_update_us": 25.707919476553798
57+
"total_min_s": 0.8090808331035078,
58+
"total_median_s": 0.8101628748700023,
59+
"per_update_us": 26.964200008660555
6060
},
6161
"tqdm": {
62-
"total_min_s": 0.3309014579281211,
63-
"total_median_s": 0.33203466702252626,
64-
"per_update_us": 11.02442916793128
62+
"total_min_s": 0.3489515413530171,
63+
"total_median_s": 0.35359816579148173,
64+
"per_update_us": 11.626556950310865
6565
},
6666
"rich": {
67-
"total_min_s": 5.2165322080254555,
68-
"total_median_s": 5.250087457709014,
69-
"per_update_us": 173.8787875045091
67+
"total_min_s": 5.103938166983426,
68+
"total_median_s": 5.121730832848698,
69+
"per_update_us": 170.12611113799116
7070
}
7171
},
7272
"excluded": {
@@ -75,27 +75,27 @@
7575
}
7676
},
7777
"scenario_c_import_time": {
78-
"interpreter_baseline_s": 0.016857875045388937,
78+
"interpreter_baseline_s": 0.014845416881144047,
7979
"libs": {
8080
"progressbar2": {
81-
"total_min_s": 0.0627057496458292,
82-
"net_ms": 45.847874600440264
81+
"total_min_s": 0.060622042044997215,
82+
"net_ms": 45.77662516385317
8383
},
8484
"tqdm": {
85-
"total_min_s": 0.0390107911080122,
86-
"net_ms": 22.152916062623262
85+
"total_min_s": 0.03654904244467616,
86+
"net_ms": 21.703625563532114
8787
},
8888
"rich": {
89-
"total_min_s": 0.06456149974837899,
90-
"net_ms": 47.703624702990055
89+
"total_min_s": 0.06204712484031916,
90+
"net_ms": 47.20170795917511
9191
},
9292
"alive-progress": {
93-
"total_min_s": 0.02484762528911233,
94-
"net_ms": 7.9897502437233925
93+
"total_min_s": 0.02296954207122326,
94+
"net_ms": 8.124125190079212
9595
},
9696
"click": {
97-
"total_min_s": 0.04090962512418628,
98-
"net_ms": 24.05175007879734
97+
"total_min_s": 0.038272291887551546,
98+
"net_ms": 23.4268750064075
9999
}
100100
}
101101
}

progressbar/bar.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -968,8 +968,12 @@ def __iter__(self):
968968
update(value)
969969
next_update = self._next_update
970970
else:
971-
# Gated out: keep bar.value live without entering the
972-
# redraw machinery (no `previous_value`/redraw change).
971+
# Gated out: advance bar.value AND previous_value (exactly
972+
# as update() would) without entering the redraw machinery,
973+
# so reads of bar.previous_value mid-loop stay identical to
974+
# the original every-iteration semantics. The gate's pixel
975+
# reference is the separate `_last_drawn_value`.
976+
self.previous_value = self.value
973977
self.value = value
974978
yield item
975979
self.finish()

tests/test_fastpath.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,13 @@ def test_value_is_live_during_iteration():
109109
# bar.value == i: value reflects items yielded so far (pre-increment),
110110
# so at the start of the body for item i, value is i (not i+1).
111111
assert bar.value == i, f'bar.value mismatch at i={i}: got {bar.value}'
112+
# previous_value stays byte-identical to the pre-gate behavior on
113+
# EVERY iteration (not just at redraws): the value before the current
114+
# one (0 for the first item, set by start()'s forced draw).
115+
expected_prev = i - 1 if i else 0
116+
assert bar.previous_value == expected_prev, (
117+
f'previous_value mismatch at i={i}: got {bar.previous_value}'
118+
)
112119
last = i
113120
assert last == 499
114121

0 commit comments

Comments
 (0)