Repository: ollama/ollama
Pull request: progress: fix bar rate

implement rate as a rolling average over the last n updates. 

the current issue is rate is calculated as an average rate over the lifetime of the progress bar. this reflects the actual progress well if the progress is smooth and flat but that's rarely the case.

the rolling average is a better way to represent changing rates. if more progress is made in the window than before, the rate will go up. if less progress is made, the rate will go down

Changed files:
filename: format/bytes.go
patch: @@ -37,6 +37,8 @@ func HumanBytes(b int64) string {
 	switch {
 	case value >= 100:
 		return fmt.Sprintf("%d %s", int(value), unit)
+	case value >= 10:
+		return fmt.Sprintf("%d %s", int(value), unit)
 	case value != math.Trunc(value):
 		return fmt.Sprintf("%.1f %s", value, unit)
 	default:

filename: progress/bar.go
patch: @@ -2,7 +2,6 @@ package progress
 
 import (
 	"fmt"
-	"math"
 	"os"
 	"strings"
 	"time"
@@ -11,12 +10,6 @@ import (
 	"golang.org/x/term"
 )
 
-type Stats struct {
-	rate      int64
-	value     int64
-	remaining time.Duration
-}
-
 type Bar struct {
 	message      string
 	messageWidth int
@@ -26,33 +19,45 @@ type Bar struct {
 	currentValue int64
 
 	started time.Time
+	stopped time.Time
+
+	maxBuckets int
+	buckets    []bucket
+}
 
-	stats   Stats
-	statted time.Time
+type bucket struct {
+	updated time.Time
+	value   int64
 }
 
 func NewBar(message string, maxValue, initialValue int64) *Bar {
-	return &Bar{
+	b := Bar{
 		message:      message,
 		messageWidth: -1,
 		maxValue:     maxValue,
 		initialValue: initialValue,
 		currentValue: initialValue,
 		started:      time.Now(),
+		maxBuckets:   10,
+	}
+
+	if initialValue >= maxValue {
+		b.stopped = time.Now()
 	}
+
+	return &b
 }
 
 // formatDuration limits the rendering of a time.Duration to 2 units
 func formatDuration(d time.Duration) string {
-	if d >= 100*time.Hour {
+	switch {
+	case d >= 100*time.Hour:
 		return "99h+"
-	}
-
-	if d >= time.Hour {
+	case d >= time.Hour:
 		return fmt.Sprintf("%dh%dm", int(d.Hours()), int(d.Minutes())%60)
+	default:
+		return d.Round(time.Second).String()
 	}
-
-	return d.Round(time.Second).String()
 }
 
 func (b *Bar) String() string {
@@ -61,59 +66,85 @@ func (b *Bar) String() string {
 		termWidth = 80
 	}
 
-	var pre, mid, suf strings.Builder
-
-	if b.message != "" {
+	var pre strings.Builder
+	if len(b.message) > 0 {
 		message := strings.TrimSpace(b.message)
 		if b.messageWidth > 0 && len(message) > b.messageWidth {
 			message = message[:b.messageWidth]
 		}
 
 		fmt.Fprintf(&pre, "%s", message)
-		if b.messageWidth-pre.Len() >= 0 {
-			pre.WriteString(strings.Repeat(" ", b.messageWidth-pre.Len()))
+		if padding := b.messageWidth - pre.Len(); padding > 0 {
+			pre.WriteString(repeat(" ", padding))
 		}
 
 		pre.WriteString(" ")
 	}
 
-	fmt.Fprintf(&pre, "%3.0f%% ", math.Floor(b.percent()))
-
-	fmt.Fprintf(&suf, "(%s/%s", format.HumanBytes(b.currentValue), format.HumanBytes(b.maxValue))
-
-	stats := b.Stats()
-	rate := stats.rate
-	if stats.value > b.initialValue && stats.value < b.maxValue {
-		fmt.Fprintf(&suf, ", %s/s", format.HumanBytes(int64(rate)))
+	fmt.Fprintf(&pre, "%3.0f%%", b.percent())
+
+	var suf strings.Builder
+	// max 13 characters: "999 MB/999 MB"
+	if b.stopped.IsZero() {
+		curValue := format.HumanBytes(b.currentValue)
+		suf.WriteString(repeat(" ", 6-len(curValue)))
+		suf.WriteString(curValue)
+		suf.WriteString("/")
+
+		maxValue := format.HumanBytes(b.maxValue)
+		suf.WriteString(repeat(" ", 6-len(maxValue)))
+		suf.WriteString(maxValue)
+	} else {
+		maxValue := format.HumanBytes(b.maxValue)
+		suf.WriteString(repeat(" ", 6-len(maxValue)))
+		suf.WriteString(maxValue)
+		suf.WriteString(repeat(" ", 7))
 	}
 
-	fmt.Fprintf(&suf, ")")
-
-	var timing string
-	if stats.value > b.initialValue && stats.value < b.maxValue {
-		timing = fmt.Sprintf("[%s:%s]", formatDuration(time.Since(b.started)), formatDuration(stats.remaining))
+	rate := b.rate()
+	// max 10 characters: "  999 MB/s"
+	if b.stopped.IsZero() && rate > 0 {
+		suf.WriteString("  ")
+		humanRate := format.HumanBytes(int64(rate))
+		suf.WriteString(repeat(" ", 6-len(humanRate)))
+		suf.WriteString(humanRate)
+		suf.WriteString("/s")
+	} else {
+		suf.WriteString(repeat(" ", 10))
 	}
 
-	// 44 is the maximum width for the stats on the right of the progress bar
-	pad := 44 - suf.Len() - len(timing)
-	if pad > 0 {
-		suf.WriteString(strings.Repeat(" ", pad))
+	// max 8 characters: "  59m59s"
+	if b.stopped.IsZero() && rate > 0 {
+		suf.WriteString("  ")
+		var remaining time.Duration
+		if rate > 0 {
+			remaining = time.Duration(int64(float64(b.maxValue-b.currentValue)/rate)) * time.Second
+		}
+
+		humanRemaining := formatDuration(remaining)
+		suf.WriteString(repeat(" ", 6-len(humanRemaining)))
+		suf.WriteString(humanRemaining)
+	} else {
+		suf.WriteString(repeat(" ", 8))
 	}
-	suf.WriteString(timing)
 
-	// add 3 extra spaces: 2 boundary characters and 1 space at the end
-	f := termWidth - pre.Len() - suf.Len() - 3
+	var mid strings.Builder
+	// add 5 extra spaces: 2 boundary characters and 1 space at each end
+	f := termWidth - pre.Len() - suf.Len() - 5
 	n := int(float64(f) * b.percent() / 100)
 
-	if f > 0 {
-		mid.WriteString("▕")
-		mid.WriteString(strings.Repeat("█", n))
-		if f-n > 0 {
-			mid.WriteString(strings.Repeat(" ", f-n))
-		}
-		mid.WriteString("▏")
+	mid.WriteString(" ▕")
+
+	if n > 0 {
+		mid.WriteString(repeat("█", n))
 	}
 
+	if f-n > 0 {
+		mid.WriteString(repeat(" ", f-n))
+	}
+
+	mid.WriteString("▏ ")
+
 	return pre.String() + mid.String() + suf.String()
 }
 
@@ -123,6 +154,21 @@ func (b *Bar) Set(value int64) {
 	}
 
 	b.currentValue = value
+	if b.currentValue >= b.maxValue {
+		b.stopped = time.Now()
+	}
+
+	// throttle bucket updates to 1 per second
+	if len(b.buckets) == 0 || time.Since(b.buckets[len(b.buckets)-1].updated) > time.Second {
+		b.buckets = append(b.buckets, bucket{
+			updated: time.Now(),
+			value:   value,
+		})
+
+		if len(b.buckets) > b.maxBuckets {
+			b.buckets = b.buckets[1:]
+		}
+	}
 }
 
 func (b *Bar) percent() float64 {
@@ -133,41 +179,37 @@ func (b *Bar) percent() float64 {
 	return 0
 }
 
-func (b *Bar) Stats() Stats {
-	if time.Since(b.statted) < time.Second {
-		return b.stats
-	}
-
-	switch {
-	case b.statted.IsZero():
-		b.stats = Stats{
-			value:     b.initialValue,
-			rate:      0,
-			remaining: 0,
-		}
-	case b.currentValue >= b.maxValue:
-		b.stats = Stats{
-			value:     b.maxValue,
-			rate:      0,
-			remaining: 0,
-		}
-	default:
-		rate := b.currentValue - b.stats.value
-		var remaining time.Duration
-		if rate > 0 {
-			remaining = time.Second * time.Duration((float64(b.maxValue-b.currentValue))/(float64(rate)))
-		} else {
-			remaining = time.Duration(math.MaxInt64)
+func (b *Bar) rate() float64 {
+	var numerator, denominator float64
+
+	if !b.stopped.IsZero() {
+		numerator = float64(b.currentValue - b.initialValue)
+		denominator = b.stopped.Sub(b.started).Round(time.Second).Seconds()
+	} else {
+		switch len(b.buckets) {
+		case 0:
+			// noop
+		case 1:
+			numerator = float64(b.buckets[0].value - b.initialValue)
+			denominator = b.buckets[0].updated.Sub(b.started).Round(time.Second).Seconds()
+		default:
+			first, last := b.buckets[0], b.buckets[len(b.buckets)-1]
+			numerator = float64(last.value - first.value)
+			denominator = last.updated.Sub(first.updated).Round(time.Second).Seconds()
 		}
+	}
 
-		b.stats = Stats{
-			value:     b.currentValue,
-			rate:      rate,
-			remaining: remaining,
-		}
+	if denominator != 0 {
+		return numerator / denominator
 	}
 
-	b.statted = time.Now()
+	return 0
+}
+
+func repeat(s string, n int) string {
+	if n > 0 {
+		return strings.Repeat(s, n)
+	}
 
-	return b.stats
+	return ""
 }

filename: progress/spinner.go
patch: @@ -40,8 +40,8 @@ func (s *Spinner) String() string {
 		}
 
 		fmt.Fprintf(&sb, "%s", message)
-		if s.messageWidth-sb.Len() >= 0 {
-			sb.WriteString(strings.Repeat(" ", s.messageWidth-sb.Len()))
+		if padding := s.messageWidth - sb.Len(); padding > 0 {
+			sb.WriteString(strings.Repeat(" ", padding))
 		}
 
 		sb.WriteString(" ")