15 марта 2022 года. Был морозный весенний день. Ветер старался доказать, что он не промах и залезть под куртки, кофты и прочие принадлежности гардероба, чтобы из первых рук, куда уж придется, принести весеннее настроение через свежесть. Не очень-то у него это получалось. Причем при любом раскладе. Если попадалась хорошая куртка и не пускала незваного гостя - ветру рассказать о весне не получалось. Если же удавалось забраться за шиворот или пройтись ледяным дыханием свежести по пузу - этого уже не понимал прохожий. Кутался еще сильнее и поскорее старался уйти от этого весеннего настроения. Но это была не единственная неоднозначность. Именно 15-го марта в мир была превнесена еще одна неоднозначность, спровоцировавшая жаркие споры - релиз golang 1.18 вместе с системой generic-ов.
Сама по себе концепция дженериков не нова. Самый известный приход этой концепции в мир - java великая и ужасная. Сама конецепция понятна и даже логична в появлении. На мой взгляд главное, чего должны были достигнуть дженерики - убрать копипасту кода. Дженерики появились, позволяя писать теперь унифицированные функции вместо двух или трех под разные типы данных, но с одинаковыми операциями. Вроде проблема решена, все радуются, со всех концов слышен смех, вверх взлетают шляпы, толпа ликует, в небе разрываются салюты! Или не совсем? Для того, чтобы сломя голову бросаться решать проблему, сама проблема должна быть, а была ли она?
Много где при обьяснении дженериков я встречал реализацию функции, суммирующей разные числа - int, float32, float64. Да, пример показывает, что такое generic, проблема копипасты решена. Но как часто вы пишете функцию для сложения 2, 3 или даже 4 чисел? Все эти примеры оставляют терпкое послевкусие обмана. Сначала завлекают крупными лозунгами, мол Breaking News, прорыв года, все сюда, ваша жизнь не станет прежней! А открываешь статью и там тебе рассказывают, как сложить 2+2 (действительно, моя жизнь не станет прежней - больше не буду верить заголовкам статей!).
А если посмотреть на что-то более практическое? Мне очень понравилась идея попробовать дженерики на стеке (тем более в тот момент мне он понадобился в небольшом личном проекте). Это достаточно простая для понимания и реализации структура. На мой взгляд это идеальный подопытный для экспериментов. При любом раскладе тот или иной вариант пригодится в хозяйстве. Изначально я взял готовую библиотеку, но потом решил сравнить другую реализацию. Спойлер: библиотеку переписал на дженерики и стал использовать в своем проекте. А сравнение производительности двух решений натолкнуло меня на эту статью.
Давайте глянем на простейшую реализацию стека. Если код удобнее видеть на отдельной вкладке - исходники находятcя тут.
Реализация через интерфейсы
package main
type (
iNode struct {
val interface{}
next *iNode
}
InterfaceStack struct {
top *iNode
len int
}
)
func NewInterfaceStack() *InterfaceStack {
return &InterfaceStack{}
}
func (istack *InterfaceStack) Push(val interface{}) {
var n iNode = iNode{val: val, next: istack.top}
istack.len += 1
istack.top = &n
}
func (istack *InterfaceStack) Pop() interface{} {
if istack.len <= 0 {
return nil
}
istack.len -= 1
var n *iNode = istack.top
istack.top = n.next
return n.val
}
func (istack *InterfaceStack) Peak() interface{} {
if istack.len <= 0 {
return nil
}
return istack.top.val
}
func (istack *InterfaceStack) Len() int {
return istack.len
}
Реализация через дженерики
package main
type (
gNode[NT any] struct {
val NT
next *gNode[NT]
}
GenericStack[ST any] struct {
top *gNode[ST]
len int
}
)
func NewGenericStack[GS any]() *GenericStack[GS] {
return &GenericStack[GS]{}
}
func (gstack *GenericStack[ST]) Push(val ST) {
var n gNode[ST] = gNode[ST]{val: val, next: gstack.top}
gstack.len += 1
gstack.top = &n
}
func (gstack *GenericStack[ST]) Pop() (res ST, exists bool) {
if gstack.len <= 0 {
exists = false
return
}
gstack.len -= 1
var n *gNode[ST] = gstack.top
gstack.top = n.next
return n.val, true
}
func (gstack *GenericStack[ST]) Peak() (res ST, exists bool) {
if gstack.len <= 0 {
exists = false
return
}
return gstack.top.val, true
}
func (gstack GenericStack[ST]) Len() int {
return gstack.len
}
Принципиальных различий нет. Единственное, что дженериках функции Pop и Peak возвращают два аргумента вместо одного.
А теперь помотрим на использование
package main
import "fmt"
func main() {
// Interface
istack := NewInterfaceStack()
istack.Push(12)
istack.Push(32)
ival := istack.Pop();
if ival != nil{
if val, ok := ival.(int); !ok {
panic("wrong type in interface stack")
} else {
fmt.Printf("Got '%v' from interface stack\n", val)
}
}
// Generic
gstack := NewGenericStack[int]()
gstack.Push(54)
gstack.Push(67)
if val, exists := gstack.Pop(); exists {
fmt.Printf("Got '%v' from generic stack\n", val)
}
}
Пример кода есть (и он не просто компилируется, но еще и запускается без ошибок!) так что можно включать режим диванного критика и пройтись по всем аспектам этих двух подходов.
Реализация через интерфейсы. Универсальна под любой тип. Более того, она позволяет хранить разные объекты в одном стеке (особенно актуально, если у вас завалялись одна или две лишние целые ноги, по которым неплохо было бы пострелять). И размер бинарника будет на пару сотен байт меньше, по сравнению с дженериками. А что касается минусов - необходимо постоянно делать преобразование типов, внимательно следить за тем, что кладем в стек, продумывать обработку ошибок. Есть ненулевая вероятность, что ошибку преобразования придется обрабатывать на уровне выше, значит код будет запутаннее. А еще при рефакторинге можно позабыть поменять преобразование типов в каком-либо месте и долго искать откуда валится ошибка.
А теперь пришёл черед дженериков. Мы изначально знаем какой тип, поэтому преобразовывать ничего не нужно, если где-то попытаемся положить в стек неподходящий тип - компилятор надает по рукам. Меньше кода при использовании. Если нам понадобятся стеки нескольких типов, то создание кода будет переложено на компилятор и не потребует дополнительных усилий со стороны разработчика. Из минусов - теперь надо явно проверять наличие элемента в стеке - либо через длину, либо через возвращаемый флаг.
Мне кажется наш диванный аналитик подсуживает дженерикам! Почитать дак прямо идеальная фича. Но это всё касалось только стилистики кода. Но что же у нас есть еще для сравнения? Производительность! На просторах бескрайнего интернета очень часто в аргументации “за дженерики” аргументируют производительностью. Раз нет необходимости преобразования типов, то работать будет быстрее. И если с читабельностью кода всё действительно понятно - дженерики тут выигрывают прозрачностью использования и меньшим количеством кода, то с производительностью всё не так однозначно. Обычно ограничиваются умозаключениями: раз меньше кода исполняется, то работает производительнее. Да, логика понятна, нативна и производительность действительно зависит от количества выполняемых операций. Но вот насколько быстрее? Собственно для ответа на этот вопрос и была сделана такая простая реализация стека. Если вдруг вам потребуется полная версия, то на дженериках лежит тут , а на интерфейсах лежит тут.
Ну а теперь сами тесты. (код по прежнему можно найти тут, но там только последняя версия - отличается от кода в спойлерах немного).
код тестов интерфейсного подхода
package main
import "testing"
type iTestNode struct {
val int
}
func createINode(value int) iTestNode {
return iTestNode{value}
}
func BenchmarkInterfaceSimpleType(b *testing.B) {
st := NewInterfaceStack()
val := 1
for i := 0; i < b.N; i += 1 {
st.Push(val)
if _, ok := st.Pop().(int); !ok {
panic("Wrong type of data in stack")
}
}
}
func BenchmarkInterfaceSimpleCustomType(b *testing.B) {
st := NewInterfaceStack()
node := createINode(12)
for i := 0; i < b.N; i += 1 {
st.Push(node)
if _, ok := st.Pop().(iTestNode); !ok {
panic("Wrong type of data in stack")
}
}
}
func BenchmarkInterfaceCustomTypePointer(b *testing.B) {
st := NewInterfaceStack()
node := createINode(12)
for i := 0; i < b.N; i += 1 {
st.Push(&node)
if _, ok := st.Pop().(*iTestNode); !ok {
panic("Wrong type of data in stack")
}
}
}
package main
import "testing"
type gTestNode struct {
UserId int64
UserName string
AccessLevel int
Telegram string
Phone string
Skype string
Slack string
Blog string
Instagram string
Facebook string
Twitter string
Avatar []byte
Status string
}
func createGNode() gTestNode {
return gTestNode{
UserId: 12,
UserName: "someuser",
AccessLevel: 99,
Telegram: @someuserr",
Phone: "123456789",
Skype: "someUser",
Slack: "someuser",
Blog: "",
Instagram: @instasomeuserr",
Facebook: "facebook.com/someuser",
Twitter: "twitter.com/someuser",
Avatar: make([]byte, 0),
Status: "ONLINE",
}
}
//go:noinline
func BenchmarkGenericSimpleType(b *testing.B) {
b.StopTimer()
st := NewGenericStackstring
val := "some string for tests"
b.StartTimer()
for i := 0; i < b.N; i += 1 {
st.Push(val)
st.Pop()
}
}
//go:noinline
func BenchmarkGenericCustomType(b *testing.B) {
b.StopTimer()
st := NewGenericStackgTestNode
node := createGNode()
b.StartTimer()
for i := 0; i < b.N; i += 1 {
st.Push(node)
st.Pop()
}
}
//go:noinline
func BenchmarkGenericCustomTypePointer(b *testing.B) {
b.StopTimer()
st := NewGenericStack*gTestNode
node := createGNode()
b.StartTimer()
for i := 0; i < b.N; i += 1 {
st.Push(&node)
st.Pop()
}
}
код тестов подхода на дженериках
package main
import "testing"
type gTestNode struct {
val int64
}
func createGNode(value int) gTestNode {
return gTestNode{value}
}
func BenchmarkGenericSimpleType(b *testing.B) {
st := NewGenericStack[int]()
val := 1
for i := 0; i < b.N; i += 1 {
st.Push(val)
st.Pop()
}
}
func BenchmarkGenericCustomType(b *testing.B) {
st := NewGenericStack[gTestNode]()
node := createGNode()
for i := 0; i < b.N; i += 1 {
st.Push(node)
st.Pop()
}
}
func BenchmarkGenericCustomTypePointer(b *testing.B) {
st := NewGenericStack[*gTestNode]()
node := createGNode()
for i := 0; i < b.N; i += 1 {
st.Push(&node)
st.Pop()
}
}
Машина, на которой проводились тесты
CPU: 8-core AMD Ryzen 7 4700U with Radeon Graphics (-MCP-)
speed/min/max: 1482/1400/2000 MHz Kernel: 5.15.85-1-MANJARO x86_64
Mem: 5500.4/31499.2 MiB (17.5%)
inxi: 3.3.24
Запускать буду не на количество итераций, а на время прохождения теста (т.е. через -benchtime=20s ). Сам код запуска тестов будет таким: go test -bench=. -benchtime=20s
. Все тесты буду запускать по 5 раз, чтобы определить порядок.
Результаты
goos: linux
goarch: amd64
pkg: github.com/HoskeOwl/goSimpleStack
cpu: AMD Ryzen 7 4700U with Radeon Graphics
BenchmarkGenericSimpleType-8 313764921 77.92 ns/op
BenchmarkGenericCustomType-8 317463438 71.97 ns/op
BenchmarkGenericCustomTypePointer-8 218245879 111.0 ns/op
BenchmarkInterfaceSimpleType-8 213324286 113.0 ns/op
BenchmarkInterfaceSimpleCustomType-8 222740674 112.8 ns/op
BenchmarkInterfaceCustomTypePointer-8 218896858 111.9 ns/op
Неплохо, разница не в 2 раза, но она видна. Конечно для профита нужно, чтобы сервис был действительно нагруженным. В противном случае из плюсов остается только синтаксис. Но в этих тестах есть несколько смущающих меня моментов.
1) BenchmarkGenericCustomTypePointer сильно выбивается по времени от остальных собратьев;
2) по факту тут у нас не только код работы стека, но и создание объектов. А что если создание объектов вынести за цикл? Ну и чтобы всё было по взрослому - вообще его не учитывать через StopTimer
и StartTimer
.
Результаты, не учитывающие время создания объектов
goos: linux
goarch: amd64
pkg: github.com/HoskeOwl/goSimpleStack
cpu: AMD Ryzen 7 4700U with Radeon Graphics
BenchmarkGenericSimpleType-8 353000452 71.54 ns/op
BenchmarkGenericCustomType-8 338031572 73.45 ns/op
BenchmarkGenericCustomTypePointer-8 323049594 73.77 ns/op
BenchmarkInterfaceSimpleType-8 297199362 78.44 ns/op
BenchmarkInterfaceSimpleCustomType-8 298851950 81.57 ns/op
BenchmarkInterfaceCustomTypePointer-8 291001880 83.04 ns/op
PASS
ok github.com/HoskeOwl/goSimpleStack 191.971s
“А говорят, что дженерики то не настоящие!” - единственное, что хочется выкрикнуть после такого теста. Как только начинаем убирать части программы, не относящиеся к механизмам дженериков, то разница в производительности стремительно сокращается. Плюс мы видим, что BenchmarkGenericCustomTypePointer пришел в норму, значит проблема не в реализации стека. Но есть еще одна вещь, которая также вносит свою лепту - оптимизация компилятора. Давайте отключим и её, добавив нотацию //go:noinline
для каждой функции бенчмарка.
Финальный результат
goos: linux
goarch: amd64
pkg: github.com/HoskeOwl/goSimpleStack
cpu: AMD Ryzen 7 4700U with Radeon Graphics
BenchmarkGenericSimpleType-8 323416230 72.84 ns/op
BenchmarkGenericCustomType-8 325032516 71.51 ns/op
BenchmarkGenericCustomTypePointer-8 320308387 75.60 ns/op
BenchmarkInterfaceSimpleType-8 290263794 81.65 ns/op
BenchmarkInterfaceSimpleCustomType-8 296999368 81.06 ns/op
BenchmarkInterfaceCustomTypePointer-8 291887139 82.64 ns/op
PASS
ok github.com/HoskeOwl/goSimpleStack 190.310s
Во всех тестах порядок был одинаков. И что мы получили по итогу? Да, дженерики быстрее, но не на много.
UPD: как правильно заметили в комментариях - всё зависит от нагруженности сервиса. В данном случе вывод под ненагруженный проект, в ашем случае 10% может быть значительным приростом.
Вместо итога: на мой взгляд дженерики - хорошее начинание в golang. Я бы не сказал, что с их выходом стоит бежать и переписывать всю свою старую кодовую базу - профита, по производительности, скорее всего не будет. А вот новый код я бы рекомендовал писать через дженерики. Читабельность возрастёт, а количество ситуаций, в которых удастся выстрелить себе в ногу - уменьшится.
И напоследок для самых пытливых - а что произойдет если мы вместо простой структуры с одним полем будем использовать более сложную? С количеством полей от 10.
Ответ
goos: linux
goarch: amd64
pkg: github.com/HoskeOwl/goSimpleStack
cpu: AMD Ryzen 7 4700U with Radeon Graphics
BenchmarkGenericSimpleType-8 261289566 90.09 ns/op
BenchmarkGenericCustomType-8 99811050 228.6 ns/op
BenchmarkGenericCustomTypePointer-8 291460882 80.94 ns/op
BenchmarkInterfaceSimpleType-8 163295492 149.5 ns/op
BenchmarkInterfaceSimpleCustomType-8 84240980 285.6 ns/op
BenchmarkInterfaceCustomTypePointer-8 311288221 78.62 ns/op
PASS
ok github.com/HoskeOwl/goSimpleStack 183.679s
Ожидаемо - большие структуры много времени забирают на копирование, в этом случае - дженерики вас не спасут. А положение исправит передача структур через ссылки, причём в обоих случаях.
Комментарии (28)
JekaMas
22.01.2023 20:08+1А чего автор статьи хотел бы от generics?
HoskeOwl Автор
22.01.2023 23:24Я только в начале пути golang, поэтому мне сложно сейчас будет сформулировать свои ожидания. На текущий момент исследую возможности языка. Но как только за плечами будет больше проектов, то мне действительно будет интересно обдумать ответ на данный вопрос. (Просто еще мало шишек набил на golang, чтобы понимать какие еще боли можно решить с помощью дженериков, а для каких лучше придумать другой механизм)
JekaMas
23.01.2023 06:33Тогда как какие-то выводы возможны? Не лучше ли вместо не очень ясных бенчмарков попробовать найти случаи, где generics нужны в golang? Попробуйте посмотреть на рефакторинг пакета crypto.
HoskeOwl Автор
23.01.2023 12:53Не могу уловить взаимосвязь, раскройте суть вашей претензии подробнее, пожалуйста.
AlexdeBur
22.01.2023 22:09+1С дженериками не хватает одной важной фичи (и мне непонятно, почему ее сразу не сделали) - их нельзя использовать в методах структур.
С учетом, что у меня обычно большинство функций в бизнес-коде - это как раз методы, то дженерики обычно удается применить только прям в очень библиотечных функциях.
gandjustas
22.01.2023 22:21Подскажите, а стандартную библиотеку переписали на генерики?
JekaMas
23.01.2023 08:42+1Да, начали несколько месяцев назад. Хороший пример - пакет crypto https://cs.opensource.google/go/go/+/master:src/crypto/ecdh/nist.go;l=16;drc=d88d91e32e1440307369d50ba17ce622399a8bc1
NightShad0w
22.01.2023 22:18+5Дженерики в Го внезапно подкладывают мину совершенно в другом месте, которое не заметно на лабораторных примерах.
Многие проекты на Го полагаются на рефлексию и глубокую инспекцию типов в рантайме: ORM, сериализаторы, преобразования форматов и типов, тэги структур и все такое. Код на интерфейсах успешно обрабатывается, так как вся информация о типе содержится в рефлексии. А вот дженерики, то ли еще не доделаны до конца (мой опыт с Го 1.19), то ли так и задуманы, но рефлексия на дженерик типах не работает от слова совсем.Реальный пример, на котором сам подорвался: GORM библиотека, не то чтобы эзотерическая, и достаточно популярная. Внутри, для укладывания типов в базу данных использует рефлексию. В интерфейсе - interface{}.
Мой случай - есть 5 разных типов, которые должны лежать в базе данных, дженерик функция с передачей дженерик типа в GORM API, разорвала библиотеку в клочья, когда рефлексия пошла инспектировать тип, идентифицированный в рантайме через абсолютный путь до инстанцированного типа, включая имя файла и всех директорий по дороге. И этот путь использует одинарную обратную кавычку для разделения элементов пути. Вот код GORM был буквально разорван в рантайме из-за непримитивного имени типа.Более сложные случаи, когда в базу хочется положить дженерик структуры или структуру с дженерик полем - не проверял, но предполагаю, что проблемы те же.
HoskeOwl Автор
22.01.2023 23:20Очень интересная тема исследования. В статьях говорят, что дженерики компилируют несколько версий кода с нужными типами (т.е. копипаста, но в бинарном виде). Такое поведение с рефлексией действительно неожиданное.
gandjustas
22.01.2023 22:20Это не стёб?
HoskeOwl Автор
22.01.2023 23:21Нет, а что вас именно смутило в статье?
gandjustas
23.01.2023 10:16Статья максимально однобоко рассматривает генерики.
Начнем с того, что генерики и их подобие есть во всех современных статически типизированных языках. Даже тех, которые долго сопротивлялись их введению. Значит такая фича языка востребована и полезна, решает реальные проблемы.Вы как-то усомнились в реальности решаемых проблем в рамках Go, что через чур наивно, поэтому и подумал что вся статья - стёб.
Вы рассмотрели только одну область применения генериков - обобщенные коллекции. Они, очевидно, будут обладать всеми преимуществами, которые вы обозначили в статье - проверка типов при компиляции, короче код, большая скорость работы, особенно на примитивных типах.Но чего вы не упомянули (специально или по-незнанию), что генерики позволяют писать код по-другому.
Вместоfunc check(e error) { if e != nil { panic(e) } } f, err := os.Open("/tmp/dat") check(err)
Теперь можно написать
func check[T any](T r, err error) { if err != nil { panic(e) } return f } f := check(os.Open("/tmp/dat"))
Более того, можно будет (я так понимаю не в этой версии, но в следующих) собрать типы
Option[T]
иResult[T]
с возможностью их комбинации. Что в итоге приведет к исправлению самой большой проблемы Go - обработке ошибок.HoskeOwl Автор
23.01.2023 12:51В статье я не сомнивался в решаемости проблем, вопрос был один - насколько дженерики повысят производительность в конкретном случае. Более того в статье так-же сказано, что дженерики позволяют писать более лаконичный и понятный код, о чем и сказано в вашем коментарии, насколько я вижу.
sbars
22.01.2023 23:33А Pop() в версии с дженериками можно написать менее криптовано?
в обоих путях исполнения не используется res
в одном пути не используется exists
P.S. не наезд, просто вопрос по синтаксису.
HoskeOwl Автор
23.01.2023 00:05В случае с дженериками у нас есть необходимость объявлять переменные возврата только ради значения по умолчанию для переменной типа дженерика. На текущий момент я не нашел как вернуть такое значение без указанного механизма. Т.е. использование происходит в момент, когда стек у нас пустой, в 3й строчке функции (хоть и неявное). Так что на текущий момент я не вижу других способов реализации.
Ztare
23.01.2023 00:12В дженерике шире функциональность, из-за этого получился второй параметр. По хорошему вы или в обоих случаях должны возвращать exists, или оба сводить к проверке на nil. Отсюда и жалобы на оверхед по коду
Ztare
23.01.2023 01:13И оверхед работы через interface{} какраз в перепаковке в ссылочный тип, со всеми вытекающими. Отсюда и кумулятивный оверхед по взаимодействию с памятью и GC
И это написано чуть ли не в каждой статье про них, только не в вашей.HoskeOwl Автор
23.01.2023 01:58Моя статья не про устройство дженериков, и не про устройство interface{} с преобразование типов (это две достаточно большие темы, которые тянуть на статью каждая), а только про сравнение производительности. Поэтому и глубоких теоретических материалов в ней нет.
Ztare
23.01.2023 12:47это ключевое отличие, без его описания статья не полная. И от него какраз и возникли просадки по скорости, это именно то что вы измерили в тестах
HoskeOwl Автор
23.01.2023 02:18Разверните свою мысль, пожалуйста, зачем переписывание на exists?
После получения результата из стека нам в любом случае надо преобразовать значение, чтобы смочь его использовать. Значит убирать его в текущих тестах смысла нет - тогда не понятно, что тестируется.
Ztare
23.01.2023 12:43Вариант без дженерика не предусматривает возможность положить в него реально валидный nil
Вариант с дженериком это позволяет.
Вариант с дженериком валиднее - разделена информация об отстутствии элементов в стеке и информация о содержимом элементов
qw1
23.01.2023 13:19+2Разница минимальная, потому что на каждый Push есть аллокация динамической памяти.
А теперь представим, что у нас не Stack, а Vector.
И тогда реализация через интерфейсы будет требовать аллокацию (мы же не знаем, какой объект придёт), а через дженерики Vector[int] может заранее выделить ну скажем 4кб буфер и дописывать в него значения почти моментально.
MaNaXname
24.01.2023 12:05> Все эти примеры оставляют терпкое послевкусие обмана. Сначала завлекают крупными лозунгами, мол Breaking News, прорыв года, все сюда, ваша жизнь не станет прежней! А открываешь статью и там тебе рассказывают, как сложить 2+2
Хотите посмотреть более интересную задачу для дженериков? Откройте LINQ или Hibernate. Есть аналоги в го?
qrKot
Мне кажется, вы немного зажрались (простите за мой французский, не оскорбления ради, а красного словца для).
В среднем 10-процентный прирост производительности + улучшение читабельности кода... Обычно для повышения производительности чем-то жертвовать принято, например, читабельностью. Да и заради повышения надёжности/читабельности кода как таковой вполне себе куски проектов переписывают, иногда даже на снижение производительности идут...
А тут "всего 10% в среднем" (это, так то, достаточно много) + читаемость + надёжность кода "и пусть никто не уйдет обиженным"... Ну грех такое ругать, чесслово
HoskeOwl Автор
Вопрос как обычно в нагруженности сервиса.
Давайте представим, что у нас есть небольшой домашний проект. Скажем на 100-150 человек (начальная аудитория). Код в процессе активного переписывания, допиливания и внедрения фич.
И вот если тут на одну чашу весов положить производительность, а на другю читаемость, то в этом случае я выберу читабельность.
Моя статья была с позиции домашенго пет-проекта на небольшое количество пользователей.
Когда мы приходим в мир enterprise, то там совершенно другой расклад. И иногда читабельностью жертвуют даже ради 2-3%, поскольку от общего потребления получается достаточно большая величина.
Мой вывод не панацея (это надо поправить в статье) и направлен он на небольшие советы. А для более серьёзных проектов - приведены цифры, и по ним будет возможно уже иметь картину для принятия решения на конкретном проекте.