пятница, 20 марта 2009 г.

SObjectizer4 and Multicore Scalability (век живи – век учись)

В рамках работ над седьмой бетой SObjectizer 4.4.0 потратил несколько дней на то, чтобы выяснить, почему SObjectizer 4.4.0-b6 на тесте customer_ring показывает серьезное падение производительности (результаты можно увидеть здесь).

Дело оказалось в блокировке ядра SObjectizer4, которая выполнялась внутри send_msg для поиска агента-владельца сообщения, поиска экземпляра сообщения и генерации заявок диспетчерам. Блокировка была простая, на основе обычного mutex-а. Поэтому выполнять все эти операции могла только одна нить. Как следствие, на двух ядрах две рабочие нити постоянно конкурировали друг с другом за этот mutex. И пока одна нить занималась отсылкой сообщения, вторая нить ждала первую на входе в send_msg.

В результате я перевел замок ядра SObjectizer4 с обычного mutex-а на reader-writer mutex (с помощью класса ACE_RW_Thread_Mutex). Общая производительность SObjectizer 4.4.0-b7 несколько снизилась по сравнению с 4.4.0-b6. Зато тест customer_ring на двух ядрах показывает увеличение пропускной способности, а не снижение. И, мне кажется, это очень важно.

Вот конкретные цифры для теста customer_ring (на Core2Duo):

Параметры теста

SO 4.4.0-b6

SO 4.4.0-b7

-N 10000 –M 100 395K msg/sec 361K msg/sec
-N 10000 –M 100 –D AG –G 2 172K msg/sec 392K msg/sec
-N 10000 –M 100 –D AG –G 4 250K msg/sec 294K msg/sec
-N 1000 –M 100 –D AO 425K msg/sec 425K msg/sec

Причем ACE_RW_Thread_Mutex, насколько я могу судить, далеко не самый быстрый вариант reader-writer mutex-а (по крайней мере под Windows). На тестах, когда я вместо ACE_RW_Thread_Mutex использовал счетчики на основе ACE_Atomic_Op, получались результаты в районе 600K msg/sec для двух рабочих нитей. Но проверенной реализации эффективного RW_Mutex-а на основе атомарных операций у меня нет, поэтому пока будет задействован ACE_RW_Thread_Mutex. (Я буду признателен, если кто-нибудь укажет мне на OpenSource реализацию быстрого, кроссплатформенного RW_Mutex-а под BSD/MIT-подобными лицензиями. Или даже под GPL-лицензией, чтобы хотя бы идею оттуда можно было взять.)

Блокировка ядра в SObjectizer 4.4.0-b7 в результате усложнилась. Раньше существовало всего два вида блокировок: блокировка для однофазной операции (например, для подписки события агента или отсылки не отложенного и не периодического сообщения) и блокировка для многофазной операции (например, для регистрации кооперации или отсылки отложенного/периодического сообщения). Теперь добавился еще один вид блокировки: для read-only операции (таковой сейчас является отсылка не отложенного и не периодического сообщения). Было бы желательно еще иметь и read-only многофазную операцию. Но я не придумал, как же просто и эффективно ее реализовать :( Поэтому сейчас отсылка отложенного/периодического сообщения выполняется как многофазная операция и эта операция блокирует все остальные операции (т.е. нельзя параллельно отсылать несколько отложенных сообщений). Не самая лучшая ситуация, конечно. Но хотя бы радует то, что отложенные/периодические сообщения отсылаются гораздо реже обычных сообщений.

Какой же вывод из всего вышеизложенного? А вывод очень простой: архитектура SObjectizer4, рассчитанная на использование одного общего системного словаря и адресация агентов через их имена, оказалась не приспособленной к масштабированию на multicore процессорах. Наверное, в рамках SObjectizer4 еще возможны какие-то оптимизации, способные улучшить производительность SObjectizer4 на нескольких ядрах. Но не на порядки.

Что ж, урок хоть и неприятный, но крайне полезный. Это означает, что SObjectizer5 должен строится совсем на других принципах. Наверное, вот так я это представляю себе сейчас:

Во-первых, никакого общего словаря. Большинство агентов вообще не будут иметь собственных уникальных текстовых имен. Для того, чтобы отослать сообщение конкретному агенту, отправитель сообщения должен иметь прямую ссылку на агента-получателя (например, такой ссылкой может быть некая разновидность умного указателя). Это позволит сразу получать доступ к очереди заявок данного агента, не задействуя никаких глобальных mutex-ов.

Во-вторых, никаких текстовых имен сообщений. Каждое сообщение, которое можно будет отсылать в SObjectizer, будет представлено своим “доменом” – экземпляром специального типа. Например, есть в программе необходимо сообщения типа msg_do_something, то в программе необходимо будет создать домен для этого сообщения. Например, вот так:

struct msg_do_something : public so_5::message_t
  { ... };
so_5::message_domain_t< msg_do_something > msg_do_something_domain;

Подписка и широковещательная отсылка сообщения будет происходить через домен сообщения. Скажем, вот так:

// Подписка на сообщение.
msg_do_something_domain.subscribe( my_agent_ref, &my_agent_t::evt_do_something );

// Широковещательная отсылка сообщения.
std::auto_ptr< msg_do_something > msg( new msg_do_something( ... ) );
msg_do_something_domain.send( msg );

// Целенаправленная отсылка сообщения.
std::auto_ptr< msg_do_something > msg( new msg_do_something( ... ) );
msg_do_something_domain.send( msg, my_agent_ref );

Еще одна идея, которая может пригодиться в SObjectier5. В SObjectizer4 появилось понятие многофазной операции для того, чтобы отложить операцию shutdown (т.е. изъятие информации из системных словарей и очистка некоторых указателей) до тех пор, пока не будет завершена ранее начатая длительная операция. Вот пример кода из SObjectizer4, который иллюстрирует это (отсылка отложенного/периодического сообщения):

// Если сообщение не отложенное, то нужно сразу отослать его,
// а уже затем разбираться с тем, периодическое ли оно или нет.
// После окончания этой операции ядро гарантированно должно
// быть разблокированно.
if( !delay )
	{
		external_references += deliver_msg_on_blocked_kernel(
				kernel_lock,
				to_deliver,
				insend_dispatching_enabled );
	}
else
	kernel_lock.unlock();

so_4::rt::impl::kernel_t::m_disp->push_delayed_msg(
		timer_msg_data,
		timer_delay,
		period );

Между if-ом и обращением к push_delayed_msg может произойти операция shutdown на другой нити приложения, и тогда указатель kernel_t::m_disp окажется нулевым. Со всеми вытекающими последствиями. Для того, чтобы такой ситуации не происходило в SObjectizer4 есть специальный счетчик многофазных операций и специальное событие, которое выставляется, когда shutdown разрешен.

Поскольку в SObjectizer5 нужно уйти от глобальных счетчиков и блокировок, нужно как-то решить проблему shutdown-а посреди длительной операции. Грубо говоря, чтобы kernel_t::m_disp не обнулялся пока message_domain занимается генерацией заявок.

Может быть, данная проблема вообще не возникнет, если гарантировать, что все ссылки, которыми располагает агент, не могут измениться в течении всей жизни агента. Т.е. пусть будет интерфейс so_5::runtime_t. Программист должен будет создать объект этого типа в самом начале работы приложения. Затем, ссылка на этот объект будет передаваться в конструкторе всем создающимся в программе агентам. Скажем, в базовом классе so_5::agent_t будет единственный конструктор, требующий ссылку на so_5::runtime_t:

class agent_t
	{
	private :
		so_5::runtime_t & runtime_;
	public :
		agent_t( so_5::runtime_t & rt ) : runtime_( rt )
			{}
		...
		so_5::runtime_t & runtime() const { return runtime_; }
	};

После чего везде, где у нас есть ссылка на агента, можно будет безопасно выполнять обращения к runtime:

// Где-то в дебрях отсылки отложенного сообщения...
receiver.runtime().push_delayed_msg( ... );

В таком случае на программисте будет лежать задача обеспечения корректности ссылки на so_5::runtime_t в течении всего времени работы программы. Что, как мне думается, не очень сложно. Зато в ядре SObjectizer, мне кажется, будет проще обеспечить завершение ранее начатых длительных операций при выполнении операции shutdown.

Комментариев нет: