Блог Конфуция
Хитрые строки против склеивания массива 05.02.2010

Почитывая рассылку v8-users, заметил интересную тему про оптимизации, специфичные для v8. Особенно интересным показалось сообщение о ConsStrings (cons в словаре). И вот, собственно, об этих «составных строках» мне и хочется рассказать. Так как восторг! :)

Загадка

Веб текстовый как со стороны клиента, так и со стороны сервера (особенно сервера). И всем нам постоянно приходится клеить кучу мелких строк в большие строки, а эти большие — в еще большие, и так далее. На сервере путь последней, самой большой строки (отрендеренной странички, например), оканчивается на передаче их ядру операционки для отправки по сети. В браузере, обычно, строки финишируют на присваивании node.innerHTML = string или так же, как на сервере — перед отправкой в сокет (аяксом, например).

Самая вкуснятина в том, что большинство промежуточных строк никому не нужно. Они часто даже своих переменных не имеют и являются всего лишь временными контейнерами для строк большего размера. В худшем случае на каждую конкатенацию требуется: сначала выделить память для новой строки, проинициализировать ее, защитить от сборки мусора, скопировать в выделенную память две (!) склеиваемые строки, передать полученную строку дальше, а потом уничтожить. Но всего этого можно избежать, если…

Разгадка

… если строки в вашем любимом языке нельзя изменять, то есть при любом воздействии всегда создается новая строка, а исходная строка остается нетронутой. Тогда пропадает необходимость сразу копировать конкатенируемые строки в новую область памяти, достаточно только запомнить, из каких строк состоит новая склеенная строка, а реальное копирование отложить до лучших времен. Может показаться, что все эти манипуляции только усложнят и замедлят работу со строками, но нет, все наоборот (особенно в скриптовом языке), и мы это проверим в небольшом тесте.

Судя по сообщению в рассылке, в v8 конкатенация строк построена как раз по такому принципу (в перле, помнится, это тоже уже давно есть). Цитирую это сообщение Эрика Кори: «Когда вы используете +=, V8 создает структуру типа ConsString, это узел дерева, который занимает 20 байт на 32-х разрядных машинах или 32 байта — на 64-х разрядных. Это относительно быстрая операция. Как только вы пытаетесь использовать строку для чего-то отличного от конкатенации, она превращается в простую «плоскую» строку похожим способом, как бы это сделал join (имеется ввиду Array#join()). Узел ConsString становится мусором, который мы можем собрать довольно быстро. Алгоритм, по которому должен работать join, описан в крайне загадочных спецификациях (например, работа с разреженными массивами), потому join — это не совсем простая операция…».

И последнее, что мне бы хотелось узнать (сейчас уже не осилю, но как узнаю — допишу), умеет ли кто-нибудь так же хитрить со сравнением строк. Ведь, при такой отложенной склейке строк, можно заодно складывать контрольные суммы этих строк. А когда понадобится сравнить две составные строки строки, то можно будет сначала сравнить их контрольные суммы, и только если суммы совпадают, собрать обе строки и честно сравнить.

Тестим

Сравнить производительность простой конкатенации (от которой мы всегда старались уйти) и склеивания массива очень просто. Единственный важный момент, чтобы честно сравнить время, надо попросить движок яваскрипта «собрать» полученную строку (например, пробежавшись регуляркой), если она у него хранится как дерево. Вот небольшой тестик (полная версия):


// испечем массив исходных строк
// обязательно разных и обязательно честных,
// иначе будем мерить неизвестно что
function bake (arr, prefix, count)
{
	console.time('bake')
	var rex = /xxx/
	for (var i = 0; i < count; i++)
		arr[i] = prefix + i,
		rex.test(arr[i]) // «собираем» строку
	console.timeEnd('bake')
}

var count = 100000, // сколько строк
	rex = /^\w/ // тестовый рекс

;(function(){
	var src = [], prefix = 'testA'
	bake(src, prefix, count)
	
	// берем самый неприятный случай,
	// когда мы не знаем сколько будет строк
	var joiner = []
	
	console.time('join()')
	for (var i = 0; i < count; i++)
		// а еще мы не можем использовать конструкцию joiner[i] = …
		// так как в реальной задаче у нас может не быть цикла
		joiner.push(src[i])
	
	var res = joiner.join('')
	var test = rex.test(res)
	console.timeEnd('join()')
	
	console.log(test, 'regexp test')
	console.log(res === src.join(''), 'index of prefix')
})();

;(function(){
	var src = [], prefix = 'testB'
	bake(src, prefix, count)
	
	var joiner = ''
	
	console.time('+=')
	for (var i = 0; i < count; i++)
		joiner += src[i]
	
	var res = joiner // дублируем, чтобы быть честными
	var test = rex.test(res)
	console.timeEnd('+=')
	
	console.log(test, 'regexp test')
	console.log(res === src.join(''), 'index of prefix')
})();

Тестю все на том же стареньком интеловском маке.

Результаты вполне ожидаемые. В Хроме простая конкатенация строк в два раза быстрее, чем склеивание массива строк. В Сафари строго наоборот: массив в два раза быстрее строк. Фаерфокс показывает противоречивые результаты: в 3.0 и 3.5 строки процентов на 15% быстрее, чем склейка массива, а в 3.6 наоборот — строки немного отстают. Пока сошлюсь на погрешность измерений. В Опере картинка поинтереснее, все оперы складывают строки до нескольких раз раз быстрее, чем склеивают массив (зачет!). В эксплорере тоже не все печально: шестой делает join() в 50 раз быстрее конкатенации, седьмой — так же и за то же время, а вот восьмой складывает строки примерно в два раза быстрее, чем склеивает массив, да еще делает любое из этих действий в 100 раз быстрее чем шестой или седьмой на том же железе.

Итог Если делаете сайт, который собирается проработать годы, то забудьте о создании массива только для конкатенации строк. Если же старые эксплореры будут постоянными гостями, то, увы, пока придется клеить строки через массив.
Теги:
  • клиент
  • javascript
  • performance
  • string
  • v8
Очень жду ваших комментариев на почту или на гитхаб.