Closed nilsonragee closed 2 months ago
Not gonna lie, it took me a very long time to wrap my head around vk_devmem_t
and vk_device_memory_t
. I could not understand what the difference between them was until I read this comment:
Then I realized what that was for! Although, it cost me many hours...
Here's a quick look of what I have done so far:
I really hope I did not mess things up and all counts correctly.
A review would be great 👀
r_speeds
have metrics of each usage type, including overall stats:Меня абсолютно ничего не уведомило об этом PR, нашёл его сейчас случайно. Посмотрю в лучшем случае моим вечером сегодня, но пока не могу пообещать.
Меня абсолютно ничего не уведомило об этом PR, нашёл его сейчас случайно.
Ничего страшного, все заняты делом, я все понимаю.
Посмотрю в лучшем случае моим вечером сегодня, но пока не могу пообещать.
Посидев еще подольше и добавив еще коммитов, я считаю, что тут все в порядке, косяков не должно быть. Скорее жду какие-то поправки/предложения. Но, конечно, ничего не исключено)
VK_DevMemFree
debug print:(я не проверил ещё, что alignment holes правильно считается)
@nilsoncore пинж
Стрим посмотрел. Был долгое время инактив т.к. были всякие дела.
На некоторые вопросы отвечу отдельно тут, т.к. они более общие:
_total
- это пик или максимальное значение?
A: Это максимальное значение. По общим максимальным показателям я планировал замерять общий объем проходимых данных в течение игры, сколько по итогу за сессию выделяется / освобождается такой-то памяти и т.д. Метрика, наверно, не такая полезная, потому что хватает один раз пробежаться и увидеть эти числа, т.к. значения от сессии к сессии не меняются и, в любом случае, можно высчитать эти значения самому, добавлял скорее для удобства / теста. Пики в этом плане гораздо лучше и несут полезную информацию. Думаю можно удалить _total
и добавить _peak
или _max
.CONTRIBUTING.md
, ну и соответственно я тоже решил. Плюс он мне просто понравился, решил попробовать, и читабельность в некотором плане действительно улучшается, хоть и не всегда. Я понимаю, что большинство vk
-шных модулей имеют стандартный стиль с camelCase
-ом, и по большей части я стараюсь не трогать то, что было до меня, но в данном случае была изменена / добавлена значительная часть кода, и уже изначальный код выбивался из колеи с новым, поэтому решил ручками подправить остатки. Форматтер не использовал. Если такое не нравится, могу придерживаться уже заданного стиля в файле. Тогда скорее вопрос что с этими разнобойными стилями делать, когда это все в конечном счете пойдет в апстрим.get_filename_from_filepath
на COM_FileWithoutPath
?
A: Из цели не плодить лишний код, который уже есть в движке. Да, я слышал на стриме, что COM
-овская функция очень неоптимизированная и бегает по строчке 3 раза. Но, мне кажется, это уже тогда надо в апстрим отправить, так как функция движка, а мы, вроде как, стараемся движок не трогать. Ну или заменить у нас и потом разобраться и объяснить, когда это все отправится в апстрим целиком.VkPhysicalDeviceMemoryProperties properties = vk_core.physical_device.memory_properties2.memoryProperties;
?
A: Просто так проще читать (лично для меня). Очень неудобно читать целую строчку кода обращения к 3+ вложенным полям структур. Какой-то оптимизации я не придерживался, думаю компилятор это все сократит в релизной версии, а на дебаг сборке не сильно скажется, всего несколько мест таких.VK_MemoryPropertyFlags_String
и VK_MemoryAllocateFlags_String
? Зачем им что-то возвращать?
A: Вообще это просто вспомогательные функции, главной идеей которых было упрощение громоздкого чанка кода формата %c%c%c...
и подачей туда этих флагов через varargs
до выделения небольшого массива символов со стека и подачей этого всего через простой формат %s
. Опять же, не столько какая-то оптимизация, сколько повышение читабельности и простоты кода. С точки зрения производительности посимвольный вывод может даже и получше будет, только весь этот код будет копипастой одного и того же, при чем большим куском. Хотелось бы как-то вынести все это в отдельную функцию, а лучше в какой-нибудь файл vk_flags.{h,c}
, который бы содержал подобные функции форматирования под используемые флаги, и подключить его к основному заголовку, чтоб везде доступен был, если надо.
Про PRI_VKMEMBITS
не в курсе, что это такое не видел пока.
Функции возвращают количество битов (флагов), которые проставлены (активны) в этих самах флагах. Планировал как задел на будущее, но пока сам применение не нашел. Может быть, перемудрил и это действительно лишнее.Стрим посмотрел. Был долгое время инактив т.к. были всякие дела.
Я сам в общем пропадаю, трудная неделя (две три месяц жизнь).
2. **Q: Почему код в квейковском стиле с пробелами? Используешь автоформатирование?** A: Наверно, просто потому, что оригинал использует такой стиль, Xash (вроде как) тоже придерживается этого стиля, так как это прописано в `CONTRIBUTING.md`, ну и соответственно я тоже решил. Плюс он мне просто понравился, решил попробовать, и читабельность в некотором плане действительно улучшается, хоть и не всегда. Я понимаю, что большинство `vk`-шных модулей имеют стандартный стиль с `camelCase`-ом, и по большей части я стараюсь не трогать то, что было до меня, но в данном случае была изменена / добавлена значительная часть кода, и уже изначальный код выбивался из колеи с новым, поэтому решил ручками подправить остатки. Форматтер не использовал. Если такое не нравится, могу придерживаться уже заданного стиля в файле. Тогда скорее вопрос что с этими разнобойными стилями делать, когда это все в конечном счете пойдет в апстрим.
Тут как бы такое дело :sweat_smile:. Я сам не очень понимаю, в каком стиле хочу держать код. В идеале он должен быть такой же, как и в остальном движке, но я постоянно срываюсь. Получается неконсистентно. План перед мержом его переформатировать в движковый. Возможно, имеет смысл прикрутить clang-format тот же (если он на движковый настраивается) чуть раньше. Не думал пока про это. А пока лучше просто существующее не трогать с целью только форматирования.
5. **Q: Что за функции `VK_MemoryPropertyFlags_String` и `VK_MemoryAllocateFlags_String`? Зачем им что-то возвращать?** A: Вообще это просто вспомогательные функции, главной идеей которых было упрощение громоздкого чанка кода формата `%c%c%c...` и подачей туда этих флагов через `varargs` до выделения небольшого массива символов со стека и подачей этого всего через простой формат `%s`. Опять же, не столько какая-то оптимизация, сколько повышение читабельности и простоты кода. С точки зрения производительности посимвольный вывод может даже и получше будет, только весь этот код будет копипастой одного и того же, при чем большим куском. Хотелось бы как-то вынести все это в отдельную функцию, а лучше в какой-нибудь файл `vk_flags.{h,c}`, который бы содержал подобные функции форматирования под используемые флаги, и подключить его к основному заголовку, чтоб везде доступен был, если надо.
Моё мнение, что эти функции и читаются хуже, и используются всего пару раз. И аргумент им передаётся неудобно (надо аллоцировать заранее, не вставишь аргументом напрямую. Я по ним предлагаю либо избавиться, либо переделать сигнатуру на возврат внутреннего статичного буфера.
Про `PRI_VKMEMBITS` не в курсе, что это такое не видел пока.
Это я придумал в комментарии, по аналогии с PRI64u
и друзьями. См., например, https://cplusplus.com/reference/cinttypes/
Функции возвращают количество битов (флагов), которые проставлены (активны) в этих самах флагах. Планировал как задел на будущее, но пока сам применение не нашел. Может быть, перемудрил и это действительно лишнее.
Их точно можно причесать. И счётчик там флагов не нужен, и ставить символы можно сильно проще через тернарный оператор
@nilsoncore Там ещё несколько комментариев-пожеланием остаются валидны и не адресованы.
@nilsoncore pingity ping :)
@nilsoncore мне эта штуковина может быть скоро нужна. Если у тебя нет времени, не возражаешь, если я сам её допинаю в ближайшие пару недель, основываясь на твоей работе тут?
@w23 и снова здравствуйте, я жив! К сожалению, пришлось выпасть из программирования в реал лайф по разным обстоятельствам, но не суть. В ближайшие дни еще раз пересмотрю что я там накоммитил и сильно ли что-то изменилось за это время.
Помню, думал тогда над тем как реализовать учет RAM памяти, но так и не понял что с этим делать, поэтому хотел спросить у тебя и подискутировать.
@nilsoncore мне эта штуковина может быть скоро нужна. Если у тебя нет времени, не возражаешь, если я сам её допинаю в ближайшие пару недель, основываясь на твоей работе тут?
Если оно того требует, то конечно, можешь допилить самостоятельно, это же не отдельный модуль со своей лицензией и т.д., мы тут за общее дело)
@w23 и снова здравствуйте, я жив! К сожалению, пришлось выпасть из программирования в реал лайф по разным обстоятельствам, но не суть. В ближайшие дни еще раз пересмотрю что я там накоммитил и сильно ли что-то изменилось за это время.
Норм. Спасибо!
Помню, думал тогда над тем как реализовать учет RAM памяти, но так и не понял что с этим делать, поэтому хотел спросить у тебя и подискутировать.
Можно обсудить, да.
Если коротко: нужно будет вообще все-все вызовы Mem_Malloc()
в нашем рендере превратить во что-то отдельное (макрос или функцию), передающее "контекст" аллокации: модуль, локацию (имя файла+строку+функцию), printf-like пометку к аллокации (например, имя модели).
Это можно делать итеративно. Начать с простого -- добавить по аналогии с логами enum для всех известных модулей, и передавать его руками. В недрах трекалки мы просто имеем табличку со счётчиком аллокаций для каждого модуля.
Более сложные детали (локации, кастомную инфу) можно будет добавлять потом по мере надобности. С ходу я пока не знаю, на каких структурах данных лучше такую инфу хранить.
Можно трекать утечки.
Дополнительно может иметь смысл заметную часть предаллоцированных статичных буферов заменить на одноразовую аллокацию при инициализации. Почему это полезно:
static
и прочие глобальные массивы иначе туда не попадут, и мы тупо не знаем общее потребление памяти.@nilsoncore мне эта штуковина может быть скоро нужна. Если у тебя нет времени, не возражаешь, если я сам её допинаю в ближайшие пару недель, основываясь на твоей работе тут?
Если оно того требует, то конечно, можешь допилить самостоятельно, это же не отдельный модуль со своей лицензией и т.д., мы тут за общее дело)
Пока потребность чуть сдвинулась в будущее куда-то.
Так, ответил на комментарии ревью, которые еще висят активными. Они все Outdated
, т.к. код уже поменялся, плюс я скинул ссылки на код, чтобы можно было эти изменения посмотреть.
Касательно PRI_VKMEMBITS
: Охх, зря я туда полез...
Долго не мог понять как именно это должно было работать, поэтому как-то решил это оставить и прогресс не шел.
Это я придумал в комментарии, по аналогии с PRI64u и друзьями. См., например, https://cplusplus.com/reference/cinttypes/
Вот эта наводка была очень полезной. По-началу, правда, я не понял смысла, т.к. мы сгенерируем формат, а аргументы потом нужно влепить вручную, так еще и неизвестно сколько этих аргументов нужно. Но потом до меня допёрло, можно так же и аргументы всунуть через макрос. Перед этим я придумал еще один подход, который создает нужную переменную через макрос. В общем, напишу варианты, а там уже можно выбрать на вкус и цвет:
// Creates a new stack-based string ( char[N] ) type variable to shortly represent value of `VkMemoryPropertyFlags`.
#define VKMEMPROPFLAGS_STRVAR( variable, property_flags ) \
char variable[6]; \
variable[0] = ( property_flags & VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT ) ? 'D' : '-'; \
variable[1] = ( property_flags & VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT ) ? 'V' : '-'; \
variable[2] = ( property_flags & VK_MEMORY_PROPERTY_HOST_COHERENT_BIT ) ? 'C' : '-'; \
variable[3] = ( property_flags & VK_MEMORY_PROPERTY_HOST_CACHED_BIT ) ? '$' : '-'; \
variable[4] = ( property_flags & VK_MEMORY_PROPERTY_LAZILY_ALLOCATED_BIT ) ? 'L' : '-'; \
variable[5] = '\0';
...
if ( g_devmem.verbose ) {
VKMEMALLOCFLAGS_STRVAR( allocate_flags_str, allocate_flags );
gEngine.Con_Reportf( " ^3->^7 ^6AllocateDeviceMemory:^7 { size: %llu, memoryTypeBits: 0x%x, allocate_flags: %s => typeIndex: %d }\n",
mai.allocationSize, req.memoryTypeBits, allocate_flags_str, mai.memoryTypeIndex );
}
// Format for printf-like functions to represent bits of `VkMemoryPropertyFlags`.
#define PRI_VKMEMPROPFLAGS_FMT "%c%c%c%c%c"
// Inline arguments for `PRI_VKMEMPROPFLAGS_FMT` format macro.
#define PRI_VKMEMPROPFLAGS_ARG( flags ) \
( flags & VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT ) ? 'D' : '-', \
( flags & VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT ) ? 'V' : '-', \
( flags & VK_MEMORY_PROPERTY_HOST_COHERENT_BIT ) ? 'C' : '-', \
( flags & VK_MEMORY_PROPERTY_HOST_CACHED_BIT ) ? '$' : '-', \
( flags & VK_MEMORY_PROPERTY_LAZILY_ALLOCATED_BIT ) ? 'L' : '-'
...
if ( g_devmem.verbose ) {
gEngine.Con_Reportf( " ^3->^7 ^6AllocateDeviceMemory:^7 { size: %llu, memoryTypeBits: 0x%x, allocate_flags: " PRI_VKMEMALLOCFLAGS_FMT " => typeIndex: %d }\n",
mai.allocationSize, req.memoryTypeBits, PRI_VKMEMALLOCFLAGS_ARG( allocate_flags ), mai.memoryTypeIndex );
}
Когда до меня наконец допёрло что ты имел в виду, то да, я согласен, 2-ое смотрится и проще, и понятнее.
Вообще, можно наверно и какой-то общий макрос сделать, в который можно передавать нужный тип, и он уже по нему будет выбирать нужный формат... Но не знаю как это можно сделать через одни макросы, мне кажется либо придется городить какую-то огроменную структуру макросов, либо ставить if
-ы/switch
-и и проверять это все в рантайме.
Касательно ситуации с огромным макросом REGISTER_STATS_METRICS
, который было предложено оформить через X-макросы:
Думаю, лучше сразу вставить код сюда, чтобы было видно и не искать лишний раз:
// Register single stats variable.
#define REGISTER_STATS_METRIC( var, metric_name, var_name, metric_type ) \
R_SpeedsRegisterMetric( &(var), MODULE_NAME, #metric_name, metric_type, /*reset*/ false, #var_name, __FILE__, __LINE__ );
// NOTE(nilsoncore): I know, this is a mess... Sorry.
// It could have been avoided by having short `VK_DevMemUsageTypes` enum names,
// but I have done it this way because I want those enum names to be as descriptive as possible.
// This basically replaces those enum names with ones provided by suffixes, which are just their endings.
//
// | var | metric_name | var_name | metric_type |
// | -------------------------------- | -------------------------------------- | ----------------------------------------------------- | ------------------ |
#define REGISTER_STATS_METRICS( usage_type, usage_suffix ) { \
vk_devmem_allocation_stats_t *const stats = &g_devmem.stats[usage_type]; \
REGISTER_STATS_METRIC( stats->current.allocations, current_allocations##usage_suffix, g_devmem.stats[usage_suffix].current.allocations, kSpeedsMetricCount ); \
REGISTER_STATS_METRIC( stats->current.allocated, current_allocated##usage_suffix, g_devmem.stats[usage_suffix].current.allocated, kSpeedsMetricBytes ); \
REGISTER_STATS_METRIC( stats->current.align_holes, current_align_holes##usage_suffix, g_devmem.stats[usage_suffix].current.align_holes, kSpeedsMetricCount ); \
REGISTER_STATS_METRIC( stats->current.align_holes_size, current_align_holes_size##usage_suffix, g_devmem.stats[usage_suffix].current.align_holes_size, kSpeedsMetricBytes ); \
REGISTER_STATS_METRIC( stats->peak.allocations, peak_allocations##usage_suffix, g_devmem.stats[usage_suffix].peak.allocations, kSpeedsMetricCount ); \
REGISTER_STATS_METRIC( stats->peak.allocated, peak_allocated##usage_suffix, g_devmem.stats[usage_suffix].peak.allocated, kSpeedsMetricBytes ); \
REGISTER_STATS_METRIC( stats->peak.align_holes, peak_align_holes##usage_suffix, g_devmem.stats[usage_suffix].peak.align_holes, kSpeedsMetricCount ); \
REGISTER_STATS_METRIC( stats->peak.align_holes_size, peak_align_holes_size##usage_suffix, g_devmem.stats[usage_suffix].peak.align_holes_size, kSpeedsMetricBytes ); \
REGISTER_STATS_METRIC( stats->peak.align_hole_size, peak_align_hole_size##usage_suffix, g_devmem.stats[usage_suffix].peak.align_hole_size, kSpeedsMetricBytes ); \
}
qboolean VK_DevMemInit( void ) {
g_devmem.verbose = !!gEngine.Sys_CheckParm( "-vkdebugmem" );
// Register standalone metrics.
R_SPEEDS_METRIC( g_devmem.alloc_slots_count, "allocated_slots", kSpeedsMetricCount );
R_SPEEDS_METRIC( g_devmem.device_allocated, "device_allocated", kSpeedsMetricBytes );
// Register stats metrics for each usage type.
REGISTER_STATS_METRICS( VK_DEVMEM_USAGE_TYPE_ALL, _ALL );
REGISTER_STATS_METRICS( VK_DEVMEM_USAGE_TYPE_BUFFER, _BUFFER );
REGISTER_STATS_METRICS( VK_DEVMEM_USAGE_TYPE_IMAGE, _IMAGE );
return true;
}
Тут ситуация немного запутанная. Трюк здесь в том, что имена переменных в коде и в статистике записаны по-разному:
xxx.current.yyy
. То есть, после обращения к текущей (current
) или пиковой (peak
) статистике, мы ставим точку, т.к. это то, как мы обращаемся к полям структуры в коде.xxx.current_yyy
. Сделано это потому, что в метриках r_speeds
задан такой формат: <модуль>.<имя_метрики>
. Если оставить точку, то получится неконсистентно, нарушается логика формата и т.д. - точек в названии метрик мы не хотим, лучше обойтись подчеркиваниями (_
). Так здесь и сделано.Из-за этого имена переменных и метрик разные, поэтому мы не можем их просто так засунуть в R_SPEEDS_METRIC( ... )
. Но это ещё не всё.
2-ой трюк заключается в том, что в коде используются полные названия значений enum
-а VK_DevMemUsageTypes
, а в метриках их сокращенные версии - _ALL
, _BUFFER
, _IMAGE
- для того, чтобы не растягивать и без того очень длинные названия переменных в самих метриках. Также стоит упомянуть, что эти сокращенные суффиксы добавляются к окончанию названий метрик.
В итоге, как все это оформить красиво и не громоздко - я не знаю, постарался как мог. Если есть видение того как все это причесать, то лучше написать сразу примером; у меня осознание макросов заканчивается где-то на 1-ой глубине вложенности...
Переходя от проблем прошлого к проблемам будущего: учёт RAM памяти.
Помимо ранее названной функции Mem_Malloc()
и ей подобных, которые фактически является макросами обращения к движку:
есть ещё такой товарищ, как alolcator.{h,c}
. Кто он такой и зачем он я не знаю, но если посмотреть что там у него, то видно, что он самостоятельный и в движок не ходит:
Дальше немного расследования - и мне свериться, правильно ли я все понял, и знающим память освежить.
Если Mem_Malloc()
вызывают практически все, то у alolcator
-а не густо. У него есть 3 основные функции аллокации (и мои предположения по принципу использования):
aloPoolAllocate()
- выделяет чанк памяти;aloRingAlloc()
- выделяет "кольцо", которое будет с интервалом стираться и перезаписываться;aloIntPoolAlloc()
- выделяет чанк, который, видимо, заточен именно под int
-ы.По частоте использования выходит следующее:
Mem_Malloc()
- 46 вызовов в 19 файлах.aloPoolAllocate()
- 4 вызова в 3 файлах (1 комментарий).aloRingAlloc()
- 1 вызов в 1 файле.aloIntPoolAlloc()
- 1 вызов в 1 файле.Возникают вопросы:
alolcator
-у самостоятельность или перенаправлять вызовы в движок, как у Mem_Malloc()
?Mem_Malloc()
и alolcator
-а - вместе или раздельно?Помимо этого есть вопросы о том, как организовать метрики - какие данные мы хотим видеть?
Я себе представляю это так же, как с модулем devmem
- делаем название, к примеру, cpumem
, или просто ram
, и уже к этому "модулю" добавляем метрики. Можно взять уже те, что есть, т.к. и там, и там память:
GitHub код из форка не показывает, поэтому вставлю так:
typedef struct vk_devmem_allocation_stats_s {
// Metrics updated on every allocation and deallocation.
struct {
int allocations; // Current number of active (not freed) allocations.
int allocated; // Current size of allocated memory.
int align_holes; // Current number of alignment holes in active (not freed) allocations.
int align_holes_size; // Current size of alignment holes in active (not freed) allocations.
} current;
// Metrics updated whenever new highest value is registered.
struct {
int allocations; // Highest number of allocations made.
int allocated; // Largest size of allocated memory.
int align_holes; // Highest number of alignment holes made.
int align_holes_size; // Largest size of alignment holes made.
int align_hole_size; // Largest size of the largest alignment hole made.
} peak;
} vk_devmem_allocation_stats_t;
@w23 пинж 👀
@w23 пинж 👀
я немного тут придавлен, отвечу развёрнуто вряд ли сегодня.
неразвёрнуто:
1. Оставить `alolcator`-у самостоятельность или перенаправлять вызовы в движок, как у `Mem_Malloc()`?
alolcator это не полноценный аллокатор, а как бы помощник при выделении кусков памяти на уже существующем буфере, например в гпу памяти. То есть он в движок ходить не должен, за ислючением выделения памяти для его внутренних нужд.
2. Как считать вызовы `Mem_Malloc()` и `alolcator`-а - вместе или раздельно?
Раздельно. Alolcator-выделения должны трекаться очень контекстно-специфично, и не внутри самого алолкатора.
Помимо этого есть вопросы о том, как организовать метрики - какие данные мы хотим видеть? Я себе представляю это так же, как с модулем
devmem
- делаем название, к примеру,cpumem
, или простоram
, и уже к этому "модулю" добавляем метрики.
Вот про это мне бы репу почесать часок-другой. На выхах очень постараюсь.
@w23 пинж. (на всякий)
Кратко о том, что сделал:
mapped
в вывод.!!
, где оно было.PRI_xxxFLAGS_FMT
и PRI_xxxFLAGS_ARG( ... )
.alignment_hole
теперь напрямую берется из alo_block_t
, который возвращается при аллокации.N bytes
, N Kb
, N Mb
и т.д. Не отформатированное "сырое" значение так же выводится в под-сообщении "-> Allocated: ...
", чтобы видеть конкретное значение.Несмотря на то, что alignment_hole
теперь берется напрямую из блока alolcator
-а, статистика от этого особо не поменялась. Либо вышло так, что мне повезло, либо это фундаментально то же самое. На c1a0d
как было 332 дырки на 166 Кб, так и осталось. Надеюсь это не я накосячил опять. Снизу скрин после изменений:
И еще вопрос: это же не страшно, что моя ветка форка давно не обновлялась?
Обновить я не могу, т.к. он просит отменить все мои коммиты, a.k.a. слить все мои труды:
Плюс сейчас висит конфликт с файлом vk_devmem.c
, в который недавно были внесены изменения уже после моего форка. Мне вручную коммитами добавить это к себе или просто нужно будет тут выбрать, или что? Я в таком не шарю, опыта не было.
Плюс сейчас висит конфликт с файлом vk_devmem.c, в который недавно были внесены изменения уже после моего форка. Мне вручную коммитами добавить это к себе или просто нужно будет тут выбрать, или что? Я в таком не шарю, опыта не было.
Это уже через консоль надо. По идее нужно влить себе изменения из апстрима и смержить/ребейзнуть убрав вручную кофнликт. https://stackoverflow.com/a/7244456
Тут прямо чуть-чуть осталось:
@w23 Пинж.
В текущем состоянии PR готов к мержу, но без последнего пунктика про CPU/RAM память. Думаю, если что отдельно можно добавить другим PR, а то этот уже как-то растянулся...
(copied & pasted from Issue #502)